Repository: sw360/capycli (language: python)

Issue:
fix(project CreateBom): decode multiple purls and warn user
When creating an SBOM from a SW360 project, add all to the SBOM separated by spaces, but warn the user to manually fix the SBOM.

This is still a draft as it lacks a testcase.

Fixes #36 
fix(project CreateBom): decode multiple purls and warn user
When creating an SBOM from a SW360 project, add all to the SBOM separated by spaces, but warn the user to manually fix the SBOM.

This is still a draft as it lacks a testcase.

Fixes #36 
refactor testcases to directly use pytest
We already [use pytest](https://github.com/sw360/capycli/blob/d9b4a84c7e3861cfb289c3e03c9654a19723dd19/.github/workflows/unit-tests.yml#L41), but still [derive our test classes from traditional unittest](https://github.com/sw360/capycli/blob/d9b4a84c7e3861cfb289c3e03c9654a19723dd19/tests/test_base.py#L66).

This is [perfectly valid](https://docs.pytest.org/en/7.1.x/how-to/unittest.html), but we cannot use some nice pytest features that way. As an example, pytest has [built-in support for capturing stdout/stderr](https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html), so we could drop [our own implementation](https://github.com/sw360/capycli/blob/d9b4a84c7e3861cfb289c3e03c9654a19723dd19/tests/test_base.py#L87). I also like [pytest's more intuitive assert syntax](https://docs.pytest.org/en/7.1.x/how-to/assert.html#assert).

Concrete reason why I ask is that I recently extended our own capture_stdout implementation [to support an arbitrary number of arguments](https://github.com/sw360/capycli/commit/c3171b50f4cc6c43d1caf9a82c7e0faaef86206d) and now I noticed that with our own implemenation, I can't capture stdout and check the return value at the same time. All of that would already be available from pytest.

It would however mean a larger rework, but I think it would be possible to migrate file by file where needed.

If you're interested, @tngraf, I could try adding my new tests for #33 in pytest-style or also rewrite the "project createbom" tests in #37 to pytest style.
"project createBom" doesn't handle multiple purls
After fixing #26 in `main`, we still lack correct handling of multiple purls. CaPyCli silently takes the JSON-encoded string containing the array, so we get such a BOM:

```json
    { 
      "type": "library",
      "bom-ref": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
      "name": "acl",
      "version": "2.2.52-3.debian",
      "purl": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
```

I also think there's no perfect solution as CycloneDX allows only one purl per component, but we should at least warn the user and probably make it easy for him to select the right purl, e.g. by adding them separated by space?


Discussion / hints:

Fix patch:
diff --git a/ChangeLog.md b/ChangeLog.md
index 137b905..d7e05f8 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -10,7 +10,8 @@
 * Be more resilient about missing metadata in CycloneDX SBOMs.
 * The `-o` parameter of the command `project GetLicenseInfo` is now optional.
   But you still need this output when you want to create a Readme.
-* `project createbom` add purl, source and repository url from SW360 if available
+* `project createbom` add purls, source and repository url from SW360 if available.
+  If multiple purls are found, a warning is printed asking user to manually edit SBOM.
 * `project createbom` add SW360 source and binary attachments as external reference to SBOM.
 * `project createbom` adds SW360 project name, version and description to SBOM.
 
@@ -27,7 +28,7 @@
   * `bom map` will report matches by name, but different version **only** if `-all` has been specified.
     The original idea of CaPyCLI was to report as many potential matches as possible and to let the user
     decide which match to take by editing the SBOM. But it seems that many users did not read the documentation
-    and the expectations were different. Therefore the default behavior has been changed.  
+    and the expectations were different. Therefore the default behavior has been changed.
     The original behavior of versions prior to 2.x can be enabled via the `-all` switch.
 
 ## 2.0.0.dev (2023-05-19)
diff --git a/capycli/project/create_bom.py b/capycli/project/create_bom.py
index c22817b..4834729 100644
--- a/capycli/project/create_bom.py
+++ b/capycli/project/create_bom.py
@@ -17,6 +17,7 @@
 import capycli.common.script_base
 from capycli import get_logger
 from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport, SbomCreator
+from capycli.common.purl_utils import PurlUtils
 from capycli.common.print import print_red, print_text, print_yellow
 from capycli.main.result_codes import ResultCode
 
@@ -59,6 +60,12 @@ def create_project_bom(self, project) -> list:
                     # try another id name
                     purl = self.get_external_id("purl", release_details)
 
+                purl = PurlUtils.parse_purls_from_external_id(purl)
+                if len(purl) > 1:
+                    print_yellow("      Multiple purls added for", release["name"], release["version"])
+                    print_yellow("      You must remove all but one in your SBOM!")
+                purl = " ".join(purl)
+
                 if purl:
                     rel_item = Component(name=release["name"], version=release["version"], purl=purl, bom_ref=purl)
                 else:


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index a1306a8..54a8443 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -63,7 +63,7 @@ def __init__(self):
         self.outputformat = ""
 
 
-class TestBase(unittest.TestCase):
+class TestBasePytest:
     MYTOKEN = "MYTOKEN"
     MYURL = "https://my.server.com/"
     ERROR_MSG_NO_LOGIN = "Unable to login"
@@ -474,3 +474,7 @@ def get_cli_file_mit(self) -> str:
   <ExternalIds />
 </ComponentLicenseInformation>
             """
+
+
+class TestBase(unittest.TestCase, TestBasePytest):
+    pass
diff --git a/tests/test_create_bom.py b/tests/test_create_bom.py
index b23011c..f9cb0d0 100644
--- a/tests/test_create_bom.py
+++ b/tests/test_create_bom.py
@@ -8,16 +8,17 @@
 
 import os
 
+import pytest
 import responses
 from cyclonedx.model import ExternalReferenceType
 
 from capycli.common.capycli_bom_support import CaPyCliBom
 from capycli.main.result_codes import ResultCode
 from capycli.project.create_bom import CreateBom
-from tests.test_base import AppArguments, TestBase
+from tests.test_base import AppArguments, TestBasePytest
 
 
-class TestCreateBom(TestBase):
+class TestCreateBom(TestBasePytest):
     OUTPUTFILE = "output.json"
 
     def test_show_help(self) -> None:
@@ -31,7 +32,7 @@ def test_show_help(self) -> None:
         args.help = True
 
         out = self.capture_stdout(sut.run, args)
-        self.assertTrue("usage: CaPyCli project createbom" in out)
+        assert "usage: CaPyCli project createbom" in out
 
     @responses.activate
     def test_no_login(self) -> None:
@@ -45,11 +46,9 @@ def test_no_login(self) -> None:
         args.debug = True
         args.verbose = True
 
-        try:
+        with pytest.raises(SystemExit) as ex:
             sut.run(args)
-            self.assertTrue(False, "Failed to report login failure")
-        except SystemExit as ex:
-            self.assertEqual(ResultCode.RESULT_AUTH_ERROR, ex.code)
+        assert ResultCode.RESULT_AUTH_ERROR == ex.value.code
 
     @responses.activate
     def test_no_output_file(self) -> None:
@@ -59,18 +58,16 @@ def test_no_output_file(self) -> None:
         args.command = []
         args.command.append("project")
         args.command.append("createbom")
-        args.sw360_token = TestBase.MYTOKEN
-        args.sw360_url = TestBase.MYURL
+        args.sw360_token = TestBasePytest.MYTOKEN
+        args.sw360_url = TestBasePytest.MYURL
         args.debug = True
         args.verbose = True
 
         self.add_login_response()
 
-        try:
+        with pytest.raises(SystemExit) as ex:
             sut.run(args)
-            self.assertTrue(False, "Failed to report login failure")
-        except SystemExit as ex:
-            self.assertEqual(ResultCode.RESULT_COMMAND_ERROR, ex.code)
+        assert ResultCode.RESULT_COMMAND_ERROR == ex.value.code
 
     @responses.activate
     def test_no_project_identification(self) -> None:
@@ -82,16 +79,14 @@ def test_no_project_identification(self) -> None:
         args.command.append("createbom")
         args.debug = True
         args.verbose = True
-        args.sw360_token = TestBase.MYTOKEN
-        args.sw360_url = TestBase.MYURL
+        args.sw360_token = TestBasePytest.MYTOKEN
+        args.sw360_url = TestBasePytest.MYURL
 
         self.add_login_response()
 
-        try:
+        with pytest.raises(SystemExit) as ex:
             sut.run(args)
-            self.assertTrue(False, "Failed to report login failure")
-        except SystemExit as ex:
-            self.assertEqual(ResultCode.RESULT_COMMAND_ERROR, ex.code)
+        assert ResultCode.RESULT_COMMAND_ERROR == ex.value.code
 
     @responses.activate
     def test_project_not_found(self) -> None:
@@ -101,8 +96,8 @@ def test_project_not_found(self) -> None:
         args.command = []
         args.command.append("project")
         args.command.append("createbom")
-        args.sw360_token = TestBase.MYTOKEN
-        args.sw360_url = TestBase.MYURL
+        args.sw360_token = TestBasePytest.MYTOKEN
+        args.sw360_url = TestBasePytest.MYURL
         args.debug = True
         args.verbose = True
         args.id = "34ef5c5452014c52aa9ce4bc180624d8"
@@ -120,18 +115,52 @@ def test_project_not_found(self) -> None:
             adding_headers={"Authorization": "Token " + self.MYTOKEN},
         )
 
-        try:
+        with pytest.raises(SystemExit) as ex:
             sut.run(args)
-            self.assertTrue(False, "Failed to report login failure")
-        except SystemExit as ex:
-            self.assertEqual(ResultCode.RESULT_ERROR_ACCESSING_SW360, ex.code)
+        assert ResultCode.RESULT_ERROR_ACCESSING_SW360 == ex.value.code
+
+    @responses.activate
+    def test_create_bom_multiple_purls(self, capsys):
+        sut = CreateBom()
+
+        self.add_login_response()
+        sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
+
+        # the first release
+        responses.add(
+            responses.GET,
+            url=self.MYURL + "resource/api/releases/r001",
+            json=self.get_release_wheel_for_test(),
+            status=200,
+            content_type="application/json",
+            adding_headers={"Authorization": "Token " + self.MYTOKEN},
+        )
+
+        # the second release
+        release = self.get_release_cli_for_test()
+        # use a specific purl
+        release["externalIds"]["package-url"] = "[\"pkg:deb/debian/cli-support@1.3-1\",\"pkg:pypi/cli-support@1.3\"]"
+        responses.add(
+            responses.GET,
+            url=self.MYURL + "resource/api/releases/r002",
+            json=release,
+            status=200,
+            content_type="application/json",
+            adding_headers={"Authorization": "Token " + self.MYTOKEN},
+        )
+
+        cdx_components = sut.create_project_bom(self.get_project_for_test())
+        captured = capsys.readouterr()
+
+        assert "Multiple purls added" in captured.out
+        assert cdx_components[0].purl == "pkg:deb/debian/cli-support@1.3-1 pkg:pypi/cli-support@1.3"
 
     @responses.activate
     def test_project_by_id(self):
         sut = CreateBom()
 
         self.add_login_response()
-        sut.login(token=TestBase.MYTOKEN, url=TestBase.MYURL)
+        sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL)
 
         # the project
         project = self.get_project_for_test()
@@ -180,27 +209,27 @@ def test_project_by_id(self):
 
         cdx_bom = sut.create_project_cdx_bom("p001")
         cx_comp = cdx_bom.components[0]
-        self.assertEqual(cx_comp.purl, release["externalIds"]["package-url"])
+        assert cx_comp.purl == release["externalIds"]["package-url"]
 
         ext_refs_src_url = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_URL_COMMENT]
-        self.assertEqual(len(ext_refs_src_url), 1)
-        self.assertEqual(ext_refs_src_url[0].url, release["sourceCodeDownloadurl"])
-        self.assertEqual(ext_refs_src_url[0].type, ExternalReferenceType.DISTRIBUTION)
+        assert len(ext_refs_src_url) == 1
+        assert ext_refs_src_url[0].url == release["sourceCodeDownloadurl"]
+        assert ext_refs_src_url[0].type == ExternalReferenceType.DISTRIBUTION
 
         ext_refs_src_file = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_FILE_COMMENT]
-        self.assertEqual(len(ext_refs_src_file), 2)
-        self.assertEqual(ext_refs_src_file[0].url, release["_embedded"]["sw360:attachments"][0]["filename"])
-        self.assertEqual(ext_refs_src_file[0].type, ExternalReferenceType.DISTRIBUTION)
-        self.assertEqual(ext_refs_src_file[0].hashes[0].alg, "SHA-1")
-        self.assertEqual(ext_refs_src_file[0].hashes[0].content, release["_embedded"]["sw360:attachments"][0]["sha1"])
+        assert len(ext_refs_src_file) == 2
+        assert ext_refs_src_file[0].url == release["_embedded"]["sw360:attachments"][0]["filename"]
+        assert ext_refs_src_file[0].type == ExternalReferenceType.DISTRIBUTION
+        assert ext_refs_src_file[0].hashes[0].alg == "SHA-1"
+        assert ext_refs_src_file[0].hashes[0].content == release["_embedded"]["sw360:attachments"][0]["sha1"]
 
         ext_refs_vcs = [e for e in cx_comp.external_references if e.type == ExternalReferenceType.VCS]
-        self.assertEqual(len(ext_refs_vcs), 1)
-        self.assertEqual(ext_refs_vcs[0].url, release["repository"]["url"])
+        assert len(ext_refs_vcs) == 1
+        assert ext_refs_vcs[0].url == release["repository"]["url"]
 
-        self.assertEqual(cdx_bom.metadata.component.name, project["name"])
-        self.assertEqual(cdx_bom.metadata.component.version, project["version"])
-        self.assertEqual(cdx_bom.metadata.component.description, project["description"])
+        assert cdx_bom.metadata.component.name == project["name"]
+        assert cdx_bom.metadata.component.version == project["version"]
+        assert cdx_bom.metadata.component.description == project["description"]
 
     @responses.activate
     def test_project_show_by_name(self):
@@ -210,8 +239,8 @@ def test_project_show_by_name(self):
         args.command = []
         args.command.append("project")
         args.command.append("createbom")
-        args.sw360_token = TestBase.MYTOKEN
-        args.sw360_url = TestBase.MYURL
+        args.sw360_token = TestBasePytest.MYTOKEN
+        args.sw360_url = TestBasePytest.MYURL
         args.debug = True
         args.verbose = True
         args.name = "CaPyCLI"
@@ -288,15 +317,15 @@ def test_project_show_by_name(self):
         out = self.capture_stdout(sut.run, args)
         # self.dump_textfile(out, "DUMP.TXT")
 
-        self.assertTrue("Searching for project..." in out)
-        self.assertTrue("Project name: CaPyCLI, 1.9.0" in out)
-        self.assertTrue("cli-support 1.3" in out)
-        self.assertTrue("wheel 0.38.4" in out)
+        assert "Searching for project..." in out
+        assert "Project name: CaPyCLI, 1.9.0" in out
+        assert "cli-support 1.3" in out
+        assert "wheel 0.38.4" in out
 
-        self.assertTrue(os.path.isfile(self.OUTPUTFILE))
+        assert os.path.isfile(self.OUTPUTFILE)
         sbom = CaPyCliBom.read_sbom(self.OUTPUTFILE)
-        self.assertIsNotNone(sbom)
-        self.assertEqual(2, len(sbom.components))
+        assert sbom is not None
+        assert 2 == len(sbom.components)
 
         self.delete_file(self.OUTPUTFILE)