Skip to content

Commit 9fc932c

Browse files
committed
feat(sw360_objects): add package URL support
1 parent ad66e53 commit 9fc932c

File tree

6 files changed

+301
-21
lines changed

6 files changed

+301
-21
lines changed

poetry.lock

Lines changed: 79 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
[tool.poetry.dependencies]
2626
python = "^3.8" # because 3.8 is the minimum requirement for isort, mypy
2727
requests = "^2.32.2" # fix CVE-2024-35195
28+
packageurl-python = ">=0.11, <1.0"
2829

2930
[tool.poetry.group.dev.dependencies]
3031
colorama = "^0.4.6"

sw360/sw360_objects.py

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import re
1111
import os
12+
import json
13+
import packageurl
1214

1315
"""Preview of High-Level, object oriented Python interface to the SW360 REST API.
1416
For now, this does NOT strive to be stable or complete. Feel free to use it as
@@ -85,18 +87,49 @@ def _parse_link(self, key, links_key, links_value):
8587
self.details.setdefault(key, {})
8688
self.details[key][links_key] = links_value
8789

90+
def _parse_purls(self, purl_value):
91+
"""Parse package url strings"""
92+
purls = []
93+
if type(purl_value) is str:
94+
if purl_value.startswith("["):
95+
# as of 2022-04, SW360 returns arrays as JSON string...
96+
purl_value = json.loads(purl_value)
97+
else:
98+
purl_value = purl_value.split()
99+
100+
for purl_string in purl_value:
101+
if purl_string.startswith("pkg:"):
102+
try:
103+
purl = packageurl.PackageURL.from_string(purl_string)
104+
purls.append(purl)
105+
except ValueError:
106+
pass
107+
return purls
108+
88109
_camel_case_pattern = re.compile(r'(?<!^)(?=[A-Z])')
89110

90111
def from_json(self, json, copy_attributes=list(), snake_case=True):
91112
"""`copy_attributes` will be copied as-is between this instance's
92113
attributes and JSON members. If `snake_case` is set, more Python-ish
93114
snake_case names will be used (project_type instead of projectType).
94115
"""
116+
# delete purl list as we add purls from different external ids below
117+
self.purls = []
95118
for key, value in json.items():
96119
if key in copy_attributes:
97120
if snake_case:
98121
key = self._camel_case_pattern.sub('_', key).lower()
99-
self.__setattr__(key, value)
122+
if key == "external_ids":
123+
for id_type, id_value in value.items():
124+
# detect purls independent from id_type - it should be
125+
# 'package-url', but some use "purl", "purl.id", etc.
126+
purls = self._parse_purls(id_value)
127+
if len(purls):
128+
self.purls += purls
129+
continue
130+
self.external_ids[id_type] = id_value
131+
else:
132+
self.__setattr__(key, value)
100133
elif key in ("_links", "_embedded"):
101134
for links_key, links_value in value.items():
102135
self._parse_link(key, links_key, links_value)
@@ -146,6 +179,8 @@ class Release(SW360Resource):
146179
def __init__(self, json=None, release_id=None, component_id=None,
147180
name=None, version=None, downloadurl=None, sw360=None, **kwargs):
148181
self.attachments = {}
182+
self.external_ids = {}
183+
self.purls = []
149184

150185
self.name = name
151186
self.version = version
@@ -158,14 +193,17 @@ def from_json(self, json):
158193
belongs to will be extracted and stored in the `component_id`
159194
attribute.
160195
196+
SW360 external ids will be stored in the `external_ids` attribute.
197+
If valid package URLs (https://github.com/package-url/purl-spec) are found
198+
in the external ids, they will be stored in the `purls` attribute as
199+
packageurl.PackageURL instances.
200+
161201
All details not directly supported by this class will be stored as-is
162-
in the `details` instance attribute. For now, this also includes
163-
external ids which will be stored as-is in `details['externalIds'].
164-
Please note that this might change in future if better abstractions
165-
will be added in this Python library."""
202+
in the `details` instance attribute. Please note that this might
203+
change in future if more abstractions will be added here."""
166204
super().from_json(
167205
json,
168-
copy_attributes=("name", "version", "downloadurl"))
206+
copy_attributes=("name", "version", "downloadurl", "externalIds"))
169207

170208
def get(self, sw360=None, id_=None):
171209
"""Retrieve/update release from SW360."""
@@ -233,6 +271,11 @@ def from_json(self, json):
233271
support parsing the resource the attachment belongs to, so this needs
234272
to be set via constructur.
235273
274+
SW360 external ids will be stored in the `external_ids` attribute.
275+
If valid package URLs (https://github.com/package-url/purl-spec) are found
276+
in the external ids, they will be stored in the `purls` attribute as
277+
packageurl.PackageURL instances.
278+
236279
All details not directly supported by this class will be stored as-is
237280
in the `details` instance attribute.
238281
Please note that this might change in future if more abstractions
@@ -313,11 +356,14 @@ def __init__(self, json=None, component_id=None, name=None, description=None,
313356
homepage=None, component_type=None, sw360=None, **kwargs):
314357
self.releases = {}
315358
self.attachments = {}
359+
self.external_ids = {}
360+
self.purls = []
316361

317362
self.name = name
318363
self.description = description
319364
self.homepage = homepage
320365
self.component_type = component_type
366+
321367
super().__init__(json, component_id, sw360, **kwargs)
322368

323369
def from_json(self, json):
@@ -326,16 +372,20 @@ def from_json(self, json):
326372
and stored in the `releases` instance attribue. Please note that
327373
the REST API will only provide basic information for the releases.
328374
375+
SW360 external ids will be stored in the `external_ids` attribute.
376+
If valid package URLs (https://github.com/package-url/purl-spec) are found
377+
in the external ids, they will be stored in the `purls` attribute as
378+
packageurl.PackageURL instances.
379+
329380
All details not directly supported by this class will be
330381
stored as-is in the `details` instance attribute. For now, this also
331-
includes vendor information and external ids which will be stored
332-
as-is in `details['_embedded']['sw360:vendors']` and
333-
`details['externalIds']. Please note that this might change in future
334-
if better abstractions will be added in this Python library."""
382+
includes vendor information which will be stored as-is in
383+
`details['_embedded']['sw360:vendors']`. Please note that this might
384+
change in future if more abstractions will be added here."""
335385
super().from_json(
336386
json,
337387
copy_attributes=("name", "description", "homepage",
338-
"componentType"))
388+
"componentType", "externalIds"))
339389

340390
def get(self, sw360=None, id_=None):
341391
"""Retrieve/update component from SW360."""
@@ -388,6 +438,8 @@ def __init__(self, json=None, project_id=None, name=None, version=None,
388438
description=None, visibility=None, project_type=None,
389439
sw360=None, **kwargs):
390440
self.releases = {}
441+
self.external_ids = {}
442+
self.purls = []
391443

392444
self.name = name
393445
self.version = version
@@ -402,15 +454,19 @@ def from_json(self, json):
402454
and stored in the `releases` instance attribue. Please note that
403455
the REST API will only provide basic information for the releases.
404456
457+
SW360 external ids will be stored in the `external_ids` attribute.
458+
If valid package URLs (https://github.com/package-url/purl-spec) are found
459+
in the external ids, they will be stored in the `purls` attribute as
460+
packageurl.PackageURL instances.
461+
405462
All details not directly supported by this class will be
406463
stored as-is in the `details` instance attribute. For now, this also
407-
includes linked projects and external ids. Please note that this might
408-
change in future if better abstractions will be added in this Python
409-
library."""
464+
includes linked projects. Please note that this might change in future
465+
if better abstractions will be added here."""
410466
super().from_json(
411467
json,
412468
copy_attributes=("name", "description", "version", "visibility",
413-
"projectType"))
469+
"projectType", "externalIds"))
414470

415471
def get(self, sw360=None, id_=None):
416472
"""Retrieve/update project from SW360."""

tests/test_sw360obj_component.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,40 @@ def test_get_component(self):
4545
self.assertEqual(comp.name, "acl")
4646
self.assertEqual(comp.details["somekey"], "value")
4747
self.assertEqual(len(comp.releases), 1)
48+
self.assertEqual(len(comp.purls), 0)
4849
self.assertEqual(comp.releases["7c4"].component_id, "123")
4950

51+
@responses.activate
52+
def test_get_component_with_purls(self):
53+
responses.add(
54+
responses.GET,
55+
SW360_BASE_URL + "components/123",
56+
json={
57+
'name': 'acl',
58+
'somekey': 'value',
59+
'externalIds': {
60+
'package-url': 'pkg:deb/debian/[email protected] pkg:deb/ubuntu/[email protected]'}})
61+
comp = Component().get(self.lib, "123")
62+
self.assertEqual(len(comp.purls), 2)
63+
self.assertEqual(comp.purls[0].name, "acl")
64+
self.assertNotIn("package-url", comp.external_ids)
65+
66+
@responses.activate
67+
def test_get_component_invalid_purls(self):
68+
responses.add(
69+
responses.GET,
70+
SW360_BASE_URL + "components/123",
71+
json={
72+
'name': 'acl',
73+
'somekey': 'value',
74+
'externalIds': {
75+
'package-url': 'pkg:[email protected]',
76+
'purl': 'pkg:deb/debian/[email protected]'}})
77+
comp = Component().get(self.lib, "123")
78+
self.assertEqual(len(comp.purls), 1)
79+
self.assertEqual(comp.external_ids['package-url'], 'pkg:[email protected]')
80+
self.assertEqual(comp.purls[0].name, "acl")
81+
5082

5183
if __name__ == "__main__":
5284
unittest.main()

tests/test_sw360obj_project.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,48 @@ def test_get_project(self):
4949

5050
self.assertEqual(str(proj), "MyProj 11.0 (123)")
5151

52+
@responses.activate
53+
def test_get_project_extid(self):
54+
responses.add(
55+
responses.GET,
56+
SW360_BASE_URL + "projects/123",
57+
json={
58+
'name': 'MyProj',
59+
'version': '11.0',
60+
'externalIds': {'some.id': '7105'}})
61+
proj = Project().get(self.lib, "123")
62+
self.assertEqual(proj.external_ids["some.id"], "7105")
63+
self.assertEqual(len(proj.purls), 0)
64+
65+
@responses.activate
66+
def test_get_project_purl_string(self):
67+
responses.add(
68+
responses.GET,
69+
SW360_BASE_URL + "projects/123",
70+
json={
71+
'name': 'App',
72+
'version': '11.0',
73+
'externalIds': {
74+
'package-url': 'pkg:deb/debian/[email protected]?arch=source'}})
75+
proj = Project().get(self.lib, "123")
76+
self.assertEqual(len(proj.purls), 1)
77+
self.assertEqual(proj.purls[0].name, "app")
78+
self.assertEqual(proj.purls[0].version, "11.0-1")
79+
80+
@responses.activate
81+
def test_get_project_purl_invalid(self):
82+
responses.add(
83+
responses.GET,
84+
SW360_BASE_URL + "projects/123",
85+
json={
86+
'name': 'App',
87+
'version': '11.0',
88+
'externalIds': {
89+
'package-url': 'pkg:huhu'}})
90+
proj = Project().get(self.lib, "123")
91+
self.assertEqual(len(proj.purls), 0)
92+
self.assertEqual(proj.external_ids["package-url"], "pkg:huhu")
93+
5294

5395
if __name__ == "__main__":
5496
unittest.main()

tests/test_sw360obj_release.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,82 @@ def test_get_release(self):
4848
self.assertEqual(len(r.purls), 0)
4949
self.assertEqual(r.component_id, "7b4")
5050

51+
@responses.activate
52+
def test_get_release_extid(self):
53+
responses.add(
54+
responses.GET,
55+
SW360_BASE_URL + "releases/123",
56+
json={
57+
'name': 'acl',
58+
'version': '1.4',
59+
'externalIds': {'some.id': '7105'}})
60+
r = Release().get(self.lib, "123")
61+
self.assertEqual(r.external_ids["some.id"], "7105")
62+
self.assertEqual(len(r.purls), 0)
63+
64+
@responses.activate
65+
def test_get_release_purl_string(self):
66+
responses.add(
67+
responses.GET,
68+
SW360_BASE_URL + "releases/123",
69+
json={
70+
'name': 'acl',
71+
'version': '1.4',
72+
'externalIds': {
73+
'package-url': 'pkg:deb/debian/[email protected]?arch=source'}})
74+
r = Release().get(self.lib, "123")
75+
self.assertEqual(len(r.purls), 1)
76+
self.assertEqual(r.purls[0].name, "linux")
77+
self.assertEqual(r.purls[0].version, "4.19.98-1")
78+
79+
@responses.activate
80+
def test_get_release_purl_invalid(self):
81+
responses.add(
82+
responses.GET,
83+
SW360_BASE_URL + "releases/123",
84+
json={
85+
'name': 'acl',
86+
'version': '1.4',
87+
'externalIds': {
88+
'package-url': 'pkg:huhu'}})
89+
r = Release().get(self.lib, "123")
90+
self.assertEqual(len(r.purls), 0)
91+
self.assertEqual(r.external_ids["package-url"], "pkg:huhu")
92+
93+
@responses.activate
94+
def test_get_release_purl_array(self):
95+
responses.add(
96+
responses.GET,
97+
SW360_BASE_URL + "releases/123",
98+
json={
99+
'name': 'acl',
100+
'version': '1.4',
101+
'externalIds': {
102+
'package-url': [
103+
'pkg:deb/debian/[email protected]?arch=source',
104+
'pkg:deb/debian/[email protected]%2B1?arch=source']}})
105+
r = Release().get(self.lib, "123")
106+
self.assertEqual(len(r.purls), 2)
107+
self.assertEqual(r.purls[1].name, "linux-signed-amd64")
108+
self.assertEqual(r.purls[1].version, "4.19.98+1")
109+
110+
@responses.activate
111+
def test_get_release_purl_strarray(self):
112+
# as of 2022-04, SW360 returns multiple external IDs as JSON string
113+
responses.add(
114+
responses.GET,
115+
SW360_BASE_URL + "releases/123",
116+
json={
117+
'name': 'acl',
118+
'version': '1.4',
119+
'externalIds': {
120+
'package-url': '["pkg:deb/debian/[email protected]?arch=source",'
121+
' "pkg:deb/debian/[email protected]%2B1?arch=source"]'}})
122+
r = Release().get(self.lib, "123")
123+
self.assertEqual(len(r.purls), 2)
124+
self.assertEqual(r.purls[1].name, "linux-signed-amd64")
125+
self.assertEqual(r.purls[1].version, "4.19.98+1")
126+
51127

52128
if __name__ == "__main__":
53129
unittest.main()

0 commit comments

Comments
 (0)