Skip to content

Commit 9be7e16

Browse files
committed
feat(sw360_objects): add package URL support
1 parent 35ec362 commit 9be7e16

File tree

5 files changed

+198
-17
lines changed

5 files changed

+198
-17
lines changed

poetry.lock

Lines changed: 20 additions & 2 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
@@ -26,6 +26,7 @@ classifiers = [
2626
python = "^3.7" # because 3.7 is the minimum requirement for yarl
2727
requests = "^2.31.0" # fix CVE-2023-32681
2828
urllib3 = "1.26.15"
29+
packageurl-python = ">=0.8, <1.0"
2930

3031
[tool.poetry.dev-dependencies]
3132
colorama = "0.3.7"

sw360/sw360_objects.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import re
1010
import os
11+
import json
12+
import packageurl
1113

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

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

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

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

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

316361
self.name = name
317362
self.description = description
318363
self.homepage = homepage
319364
self.component_type = component_type
365+
320366
super().__init__(json, component_id, sw360, **kwargs)
321367

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

339389
def get(self, sw360=None, id_=None):
340390
"""Retrieve/update component from SW360."""
@@ -401,15 +451,19 @@ def from_json(self, json):
401451
and stored in the `releases` instance attribue. Please note that
402452
the REST API will only provide basic information for the releases.
403453
454+
SW360 external ids will be stored in the `external_ids` attribute.
455+
If valid package URLs (https://github.com/package-url/purl-spec) are found
456+
in the external ids, they will be stored in the `purls` attribute as
457+
packageurl.PackageURL instances.
458+
404459
All details not directly supported by this class will be
405460
stored as-is in the `details` instance attribute. For now, this also
406-
includes linked projects and external ids. Please note that this might
407-
change in future if better abstractions will be added in this Python
408-
library."""
461+
includes linked projects. Please note that this might change in future
462+
if better abstractions will be added here."""
409463
super().from_json(
410464
json,
411465
copy_attributes=("name", "description", "version", "visibility",
412-
"projectType"))
466+
"projectType", "externalIds"))
413467

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

tests/test_sw360obj_component.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,40 @@ def test_get_component(self):
3636
self.assertEqual(comp.name, "acl")
3737
self.assertEqual(comp.details["somekey"], "value")
3838
self.assertEqual(len(comp.releases), 1)
39+
self.assertEqual(len(comp.purls), 0)
3940
self.assertEqual(comp.releases["7c4"].component_id, "123")
4041

42+
@responses.activate
43+
def test_get_component_with_purls(self):
44+
responses.add(
45+
responses.GET,
46+
SW360_BASE_URL + "components/123",
47+
json={
48+
'name': 'acl',
49+
'somekey': 'value',
50+
'externalIds': {
51+
'package-url': 'pkg:deb/debian/[email protected] pkg:deb/ubuntu/[email protected]'}})
52+
comp = Component().get(self.lib, "123")
53+
self.assertEqual(len(comp.purls), 2)
54+
self.assertEqual(comp.purls[0].name, "acl")
55+
self.assertNotIn("package-url", comp.external_ids)
56+
57+
@responses.activate
58+
def test_get_component_invalid_purls(self):
59+
responses.add(
60+
responses.GET,
61+
SW360_BASE_URL + "components/123",
62+
json={
63+
'name': 'acl',
64+
'somekey': 'value',
65+
'externalIds': {
66+
'package-url': 'pkg:[email protected]',
67+
'purl': 'pkg:deb/debian/[email protected]'}})
68+
comp = Component().get(self.lib, "123")
69+
self.assertEqual(len(comp.purls), 1)
70+
self.assertEqual(comp.external_ids['package-url'], 'pkg:[email protected]')
71+
self.assertEqual(comp.purls[0].name, "acl")
72+
4173

4274
if __name__ == "__main__":
4375
unittest.main()

tests/test_sw360obj_release.py

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

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

43119
if __name__ == "__main__":
44120
unittest.main()

0 commit comments

Comments
 (0)