sw360 1.12.0.dev1__tar.gz → 1.12.0.dev2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/PKG-INFO +1 -1
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/pyproject.toml +1 -1
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/__init__.py +11 -3
- sw360-1.12.0.dev2/sw360/base.py +581 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/clearing.py +2 -2
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/components.py +128 -76
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/license.py +40 -12
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/moderationrequests.py +44 -27
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/packages.py +133 -48
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/project.py +216 -88
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/releases.py +126 -37
- sw360-1.12.0.dev2/sw360/reports.py +188 -0
- sw360-1.12.0.dev2/sw360/sorting.py +94 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360_api.py +43 -6
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360error.py +12 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360keycloak.py +9 -11
- sw360-1.12.0.dev2/sw360/vendor.py +196 -0
- sw360-1.12.0.dev2/sw360/vulnerabilities.py +80 -0
- sw360-1.12.0.dev1/sw360/base.py +0 -243
- sw360-1.12.0.dev1/sw360/vendor.py +0 -113
- sw360-1.12.0.dev1/sw360/vulnerabilities.py +0 -44
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/LICENSES/MIT.txt +0 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/License.md +0 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/Readme.md +0 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/attachments.py +0 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/py.typed +0 -0
- {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360oauth2.py +0 -0
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# -------------------------------------------------------------------------------
|
|
2
2
|
# Copyright (c) 2019-2026 Siemens
|
|
3
3
|
# All Rights Reserved.
|
|
4
|
-
# Author: thomas.graf@siemens.com
|
|
4
|
+
# Author: thomas.graf@siemens.com, mishra.gaurav@siemens.com
|
|
5
5
|
#
|
|
6
6
|
# Licensed under the MIT license.
|
|
7
7
|
# SPDX-License-Identifier: MIT
|
|
8
8
|
# -------------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
__version__ = (1,
|
|
10
|
+
__version__ = (1, 12, 0, "dev2")
|
|
11
11
|
|
|
12
|
+
from .base import SW360Response
|
|
13
|
+
from .sorting import (BaseSortMixin, ProjectSortColumn, ReleaseSortColumn,
|
|
14
|
+
SortParam)
|
|
12
15
|
from .sw360_api import SW360
|
|
13
16
|
from .sw360error import SW360Error
|
|
14
17
|
from .sw360keycloak import SW360Keycloak
|
|
@@ -18,5 +21,10 @@ __all__ = [
|
|
|
18
21
|
"SW360",
|
|
19
22
|
"SW360Error",
|
|
20
23
|
"SW360OAuth2",
|
|
21
|
-
"SW360Keycloak"
|
|
24
|
+
"SW360Keycloak",
|
|
25
|
+
"ProjectSortColumn",
|
|
26
|
+
"ReleaseSortColumn",
|
|
27
|
+
"SortParam",
|
|
28
|
+
"BaseSortMixin",
|
|
29
|
+
"SW360Response",
|
|
22
30
|
]
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# -------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2023-2026 Siemens
|
|
3
|
+
# All Rights Reserved.
|
|
4
|
+
# Authors: thomas.graf@siemens.com, mishra.gaurav@siemens.com
|
|
5
|
+
# Authors: gernot.hillier@siemens.com
|
|
6
|
+
#
|
|
7
|
+
# Licensed under the MIT license.
|
|
8
|
+
# SPDX-License-Identifier: MIT
|
|
9
|
+
# -------------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from http import HTTPStatus
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from .sorting import SortParam
|
|
21
|
+
from .sw360error import SW360Error
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
logger.setLevel(logging.WARNING)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SW360Response(Dict[str, Any]):
|
|
28
|
+
"""A regular dict response with convenience methods for HAL traversal.
|
|
29
|
+
|
|
30
|
+
Behaves identically to a plain ``dict`` for backward compatibility, but
|
|
31
|
+
adds ``linked_id()``, ``linked_ids()``, ``embedded_list()``, and
|
|
32
|
+
``embedded_lists()`` for comfortable access to ``_links`` and
|
|
33
|
+
``_embedded`` sections.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def linked_id(self, link_key: str = "self") -> Optional[str]:
|
|
37
|
+
"""Extract a resource ID from a ``_links`` entry.
|
|
38
|
+
|
|
39
|
+
Convenience shortcut for ``BaseMixin.get_linked_id(self, link_key)``.
|
|
40
|
+
|
|
41
|
+
:param link_key: the link relation key, defaults to ``"self"``
|
|
42
|
+
:return: the resource ID, or ``None`` if not available
|
|
43
|
+
"""
|
|
44
|
+
return BaseMixin.get_linked_id(self, link_key)
|
|
45
|
+
|
|
46
|
+
def linked_ids(self) -> Dict[str, str]:
|
|
47
|
+
"""Return all linked resource IDs as a dict.
|
|
48
|
+
|
|
49
|
+
Iterates all entries in ``_links`` and resolves each href to its
|
|
50
|
+
resource ID, returning a mapping of link key to ID. Useful for
|
|
51
|
+
inspecting which linked resources are available.
|
|
52
|
+
|
|
53
|
+
:return: dict mapping each link key to its resource ID
|
|
54
|
+
"""
|
|
55
|
+
links = self.get("_links")
|
|
56
|
+
if not isinstance(links, dict):
|
|
57
|
+
return {}
|
|
58
|
+
return {
|
|
59
|
+
key: BaseMixin.get_id_from_href(entry["href"])
|
|
60
|
+
for key, entry in links.items()
|
|
61
|
+
if isinstance(entry, dict) and isinstance(entry.get("href"), str)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def embedded_list(self, key: str) -> List["SW360Response"]:
|
|
65
|
+
"""Retrieve a specific embedded resource list.
|
|
66
|
+
|
|
67
|
+
Like ``BaseMixin.get_embedded()``, but each item in the returned
|
|
68
|
+
list is wrapped in ``SW360Response`` so that ``.linked_id()`` and
|
|
69
|
+
other convenience methods are available on the items.
|
|
70
|
+
|
|
71
|
+
:param key: the embedded resource key, e.g. ``"releases"``
|
|
72
|
+
:return: list of embedded resources as ``SW360Response`` objects,
|
|
73
|
+
or ``[]`` if not present
|
|
74
|
+
"""
|
|
75
|
+
return [SW360Response(item) for item in BaseMixin.get_embedded(self, key)]
|
|
76
|
+
|
|
77
|
+
def embedded_lists(self) -> Dict[str, List["SW360Response"]]:
|
|
78
|
+
"""Return all embedded resource lists as a dict.
|
|
79
|
+
|
|
80
|
+
Iterates all entries in ``_embedded`` and wraps each item in
|
|
81
|
+
``SW360Response``. Useful for inspecting which embedded resources
|
|
82
|
+
are available.
|
|
83
|
+
|
|
84
|
+
:return: dict mapping each embedded key to its list of
|
|
85
|
+
``SW360Response`` objects
|
|
86
|
+
"""
|
|
87
|
+
embedded = self.get("_embedded")
|
|
88
|
+
if not isinstance(embedded, dict):
|
|
89
|
+
return {}
|
|
90
|
+
return {
|
|
91
|
+
key: [SW360Response(item) for item in items]
|
|
92
|
+
for key, items in embedded.items()
|
|
93
|
+
if isinstance(items, list)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BaseMixin():
|
|
98
|
+
"""Python interface to the Siemens SW360 platform
|
|
99
|
+
|
|
100
|
+
Authentication against a running SW360 instance is performed using an API token.
|
|
101
|
+
The token will be sent as HTTP header using the format
|
|
102
|
+
`Authorization: <token_type> <token>`. Check your SW360 REST API
|
|
103
|
+
documentation for details on needed type and how to get the token.
|
|
104
|
+
token_type is "Bearer" for an OAuth workflow and "Token" for tokens
|
|
105
|
+
generated via the SW360 UI.
|
|
106
|
+
|
|
107
|
+
:ivar url: URL of the SW360 instance
|
|
108
|
+
:ivar token: The SW360 REST API token (the cryptic string without
|
|
109
|
+
"Authorization:" and `token_type`).
|
|
110
|
+
:ivar oauth2: flag indicating whether this is an OAuth2 token
|
|
111
|
+
:ivar default_batch_size: Default size of batch to use while fetching all items from API
|
|
112
|
+
:type url: string
|
|
113
|
+
:type token: string
|
|
114
|
+
:type oauth2: boolean
|
|
115
|
+
:type default_batch_size: int
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, url: str, token: str, oauth2: bool = False,
|
|
119
|
+
default_batch_size: int = 50) -> None:
|
|
120
|
+
"""Constructor"""
|
|
121
|
+
if url[-1] != "/":
|
|
122
|
+
url += "/"
|
|
123
|
+
self.url = url
|
|
124
|
+
self.session: Optional[requests.Session] = None
|
|
125
|
+
|
|
126
|
+
if oauth2:
|
|
127
|
+
self.api_headers = {"Authorization": "Bearer " + token}
|
|
128
|
+
else:
|
|
129
|
+
self.api_headers = {"Authorization": "Token " + token}
|
|
130
|
+
|
|
131
|
+
self.force_no_session = False
|
|
132
|
+
self.default_batch_size = default_batch_size
|
|
133
|
+
self.api_version = (18, 0)
|
|
134
|
+
|
|
135
|
+
def api_get(self, url: str = "") -> Optional[SW360Response]:
|
|
136
|
+
"""Request `url` from REST API and return json answer.
|
|
137
|
+
|
|
138
|
+
:param url: the url to be requested
|
|
139
|
+
:type url: string
|
|
140
|
+
:return: JSON data as dict with convenience methods for HAL traversal
|
|
141
|
+
:rtype: SW360Response
|
|
142
|
+
:raises SW360Error: if there is a negative HTTP response
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
if (not self.force_no_session) and self.session is None:
|
|
146
|
+
raise SW360Error(message="login_api needs to be called first")
|
|
147
|
+
|
|
148
|
+
if self.force_no_session:
|
|
149
|
+
response = requests.get(url, headers=self.api_headers)
|
|
150
|
+
else:
|
|
151
|
+
if self.session:
|
|
152
|
+
response = self.session.get(url)
|
|
153
|
+
|
|
154
|
+
if response.ok:
|
|
155
|
+
if response.status_code == HTTPStatus.NO_CONTENT:
|
|
156
|
+
return None
|
|
157
|
+
parsed = response.json()
|
|
158
|
+
if isinstance(parsed, dict):
|
|
159
|
+
return SW360Response(parsed)
|
|
160
|
+
else:
|
|
161
|
+
return parsed
|
|
162
|
+
|
|
163
|
+
raise SW360Error(response, url)
|
|
164
|
+
|
|
165
|
+
def api_get_all(self, url: str, sort: Optional[SortParam] = None,
|
|
166
|
+
batch: int = -1, page: int = 0,
|
|
167
|
+
_data: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
168
|
+
"""
|
|
169
|
+
Retrieve all pages of data from the specified URL.
|
|
170
|
+
|
|
171
|
+
:param url: The URL to request data from.
|
|
172
|
+
:param sort: The sort parameter to sort data by.
|
|
173
|
+
:param page: Page number to fetch
|
|
174
|
+
:param batch: How many rows to fetch at a time, use
|
|
175
|
+
`default_batch_size` if -1
|
|
176
|
+
:param _data: Internal param for aggregating data.
|
|
177
|
+
:return: The combined JSON data from all pages.
|
|
178
|
+
:rtype: Optional[Dict[str, Any]]
|
|
179
|
+
"""
|
|
180
|
+
if _data is None:
|
|
181
|
+
_data = {}
|
|
182
|
+
if batch == -1:
|
|
183
|
+
batch = self.default_batch_size
|
|
184
|
+
|
|
185
|
+
paginated_url = self._add_pagination(url, page, batch, sort)
|
|
186
|
+
resp = self.api_get(paginated_url)
|
|
187
|
+
if resp is not None and 'page' in resp:
|
|
188
|
+
total_pages = resp['page']['totalPages']
|
|
189
|
+
# Clean up meta info
|
|
190
|
+
if '_links' in resp:
|
|
191
|
+
del resp['_links']
|
|
192
|
+
del resp['page']
|
|
193
|
+
# Update data and get next page
|
|
194
|
+
_data = self.__merge_responses(_data, resp)
|
|
195
|
+
if page + 1 < total_pages:
|
|
196
|
+
return self.api_get_all(url, sort, batch, page + 1, _data)
|
|
197
|
+
else:
|
|
198
|
+
# Clean up meta info
|
|
199
|
+
if resp is not None and '_links' in resp:
|
|
200
|
+
del resp['_links']
|
|
201
|
+
_data = self.__merge_responses(_data, resp)
|
|
202
|
+
return _data
|
|
203
|
+
|
|
204
|
+
def __merge_responses(self, previous: Dict[str, Any],
|
|
205
|
+
next: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Function to merge previous and next response of the same API
|
|
208
|
+
recursively.
|
|
209
|
+
:param previous: The previous response data.
|
|
210
|
+
:param next: The next response data.
|
|
211
|
+
:return: The merged response data.
|
|
212
|
+
"""
|
|
213
|
+
if next is None:
|
|
214
|
+
return previous
|
|
215
|
+
for key, value in next.items():
|
|
216
|
+
if (key in previous and isinstance(previous[key], dict) and
|
|
217
|
+
isinstance(value, dict)):
|
|
218
|
+
previous[key] = self.__merge_responses(previous[key], value)
|
|
219
|
+
elif (key in previous and isinstance(previous[key], list) and
|
|
220
|
+
isinstance(value, list)):
|
|
221
|
+
previous[key].extend(value)
|
|
222
|
+
elif (key in previous and isinstance(previous[key], tuple) and
|
|
223
|
+
isinstance(value, tuple)):
|
|
224
|
+
previous[key].extend(value)
|
|
225
|
+
else:
|
|
226
|
+
previous[key] = value
|
|
227
|
+
return previous
|
|
228
|
+
|
|
229
|
+
def api_post_multipart(self, url: str = "", files: Dict[str, Any] = {}) -> Optional[requests.Response]:
|
|
230
|
+
"""
|
|
231
|
+
Send a multipart POST request to the specified URL with the provided file data.
|
|
232
|
+
|
|
233
|
+
:param url: The URL to send the multipart POST request to.
|
|
234
|
+
:type url: str
|
|
235
|
+
:param files: The dictionary containing file data to be sent in the request.
|
|
236
|
+
:type files: Dict[str, Any]
|
|
237
|
+
:return: The JSON response received from the server, if any.
|
|
238
|
+
:rtype: Optional[Dict[str, Any]]
|
|
239
|
+
:raises SW360Error: If the HTTP response indicates an error.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
if (not self.force_no_session) and self.session is None:
|
|
243
|
+
raise SW360Error(message="login_api needs to be called first")
|
|
244
|
+
|
|
245
|
+
if self.force_no_session:
|
|
246
|
+
response = requests.post(url, headers=self.api_headers, files=files)
|
|
247
|
+
else:
|
|
248
|
+
if self.session:
|
|
249
|
+
response = self.session.post(url, files=files)
|
|
250
|
+
|
|
251
|
+
if response.ok:
|
|
252
|
+
if response.status_code == HTTPStatus.NO_CONTENT: # 204 No Content
|
|
253
|
+
return None
|
|
254
|
+
return response
|
|
255
|
+
|
|
256
|
+
raise SW360Error(response, url)
|
|
257
|
+
|
|
258
|
+
def api_post(
|
|
259
|
+
self,
|
|
260
|
+
url: str = "",
|
|
261
|
+
json: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None
|
|
262
|
+
) -> Optional[requests.Response]:
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
Send a POST request to the specified URL with the provided json data.
|
|
266
|
+
|
|
267
|
+
:param url: The URL to send the POST request to.
|
|
268
|
+
:type url: str
|
|
269
|
+
:param json: The dictionary containing json data to be sent in the request.
|
|
270
|
+
:type json: Dict[str, Any]
|
|
271
|
+
:return: The JSON response received from the server, if any.
|
|
272
|
+
:rtype: Optional[Dict[str, Any]]
|
|
273
|
+
:raises SW360Error: If the HTTP response indicates an error.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
if (not self.force_no_session) and self.session is None:
|
|
277
|
+
raise SW360Error(message="login_api needs to be called first")
|
|
278
|
+
|
|
279
|
+
if self.force_no_session:
|
|
280
|
+
response = requests.post(url, headers=self.api_headers, json=json)
|
|
281
|
+
else:
|
|
282
|
+
if self.session:
|
|
283
|
+
response = self.session.post(url, json=json)
|
|
284
|
+
|
|
285
|
+
if response.ok:
|
|
286
|
+
if response.status_code == HTTPStatus.NO_CONTENT: # 204 No Content
|
|
287
|
+
return None
|
|
288
|
+
return response
|
|
289
|
+
|
|
290
|
+
raise SW360Error(response, url)
|
|
291
|
+
|
|
292
|
+
def api_patch(self, url: str = "", json: Any = {}) -> Optional[SW360Response]:
|
|
293
|
+
"""
|
|
294
|
+
Send a PATCH request to the specified URL with the provided json data.
|
|
295
|
+
|
|
296
|
+
:param url: The URL to send the PATCH request to.
|
|
297
|
+
:type url: str
|
|
298
|
+
:param json: The dictionary containing json data to be sent in the request.
|
|
299
|
+
:type json: Dict[str, Any]
|
|
300
|
+
:return: The JSON response as dict with convenience methods for HAL traversal
|
|
301
|
+
:rtype: Optional[SW360Response]
|
|
302
|
+
:raises SW360Error: If the HTTP response indicates an error.
|
|
303
|
+
"""
|
|
304
|
+
if (not self.force_no_session) and self.session is None:
|
|
305
|
+
raise SW360Error(message="login_api needs to be called first")
|
|
306
|
+
|
|
307
|
+
if self.force_no_session:
|
|
308
|
+
response = requests.patch(url, headers=self.api_headers, json=json)
|
|
309
|
+
else:
|
|
310
|
+
if self.session:
|
|
311
|
+
response = self.session.patch(url, json=json)
|
|
312
|
+
|
|
313
|
+
if response.ok:
|
|
314
|
+
if response.status_code == HTTPStatus.NO_CONTENT: # 204 No Content
|
|
315
|
+
return None
|
|
316
|
+
if response.content:
|
|
317
|
+
parsed = response.json()
|
|
318
|
+
if isinstance(parsed, dict):
|
|
319
|
+
return SW360Response(parsed)
|
|
320
|
+
else:
|
|
321
|
+
return parsed
|
|
322
|
+
else:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
raise SW360Error(response, url)
|
|
326
|
+
|
|
327
|
+
def api_delete(self, url: str = "") -> Optional[requests.Response]:
|
|
328
|
+
"""Send a DELETE request to the specified `url` of the REST API and return JSON response.
|
|
329
|
+
|
|
330
|
+
:param url: The URL to which the DELETE request will be sent.
|
|
331
|
+
:type url: str
|
|
332
|
+
:return: JSON data returned by the API, or None if the response is empty.
|
|
333
|
+
:rtype: Optional[Dict[str, Any]]
|
|
334
|
+
:raises SW360Error: If the API responds with a non-success HTTP status code.
|
|
335
|
+
"""
|
|
336
|
+
if (not self.force_no_session) and self.session is None:
|
|
337
|
+
raise SW360Error(message="login_api needs to be called first")
|
|
338
|
+
|
|
339
|
+
if self.force_no_session:
|
|
340
|
+
response = requests.delete(url, headers=self.api_headers)
|
|
341
|
+
else:
|
|
342
|
+
if self.session:
|
|
343
|
+
response = self.session.delete(url)
|
|
344
|
+
|
|
345
|
+
if response.ok:
|
|
346
|
+
if response.status_code == HTTPStatus.NO_CONTENT:
|
|
347
|
+
return None
|
|
348
|
+
return response
|
|
349
|
+
|
|
350
|
+
raise SW360Error(response, url)
|
|
351
|
+
|
|
352
|
+
def _update_external_ids(self, current_data: Dict[str, Any], ext_id_name: str, ext_id_value: str,
|
|
353
|
+
update_mode: str) -> Tuple[Optional[Any], Dict[str, Dict[str, str]], bool]:
|
|
354
|
+
"""Internal helper function to prepare an update/addition of external
|
|
355
|
+
id while preserving the others."""
|
|
356
|
+
old_value = None
|
|
357
|
+
if "externalIds" not in current_data:
|
|
358
|
+
ext_id_data = {"externalIds": {ext_id_name: ext_id_value}}
|
|
359
|
+
else:
|
|
360
|
+
if ext_id_name in current_data["externalIds"]:
|
|
361
|
+
old_value = current_data["externalIds"][ext_id_name]
|
|
362
|
+
ext_id_data = {"externalIds": current_data["externalIds"]}
|
|
363
|
+
ext_id_data["externalIds"][ext_id_name] = ext_id_value
|
|
364
|
+
|
|
365
|
+
if update_mode == "delete":
|
|
366
|
+
del ext_id_data["externalIds"][ext_id_name]
|
|
367
|
+
|
|
368
|
+
if (update_mode == "overwrite"
|
|
369
|
+
or (update_mode == "none" and old_value is None)
|
|
370
|
+
or (update_mode == "delete" and old_value is not None)):
|
|
371
|
+
update = True
|
|
372
|
+
else:
|
|
373
|
+
update = False
|
|
374
|
+
|
|
375
|
+
return (old_value, ext_id_data, update)
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _add_params(url: str, params: Dict[str, str]) -> str:
|
|
379
|
+
"""Add the given parameter to the given url"""
|
|
380
|
+
|
|
381
|
+
query_string = urlencode(params)
|
|
382
|
+
|
|
383
|
+
if query_string == "":
|
|
384
|
+
return url
|
|
385
|
+
|
|
386
|
+
if "?" in url:
|
|
387
|
+
return f"{url}&{query_string}"
|
|
388
|
+
else:
|
|
389
|
+
return f"{url}?{query_string}"
|
|
390
|
+
|
|
391
|
+
def _add_pagination(self, url: str, page: int, page_entries: int,
|
|
392
|
+
sort: Optional[SortParam] = None) -> str:
|
|
393
|
+
"""
|
|
394
|
+
Add pagination parameters to the GET request URL
|
|
395
|
+
:param url: URL to add params to
|
|
396
|
+
:param page: Page number to fetch
|
|
397
|
+
:param page_entries: Number of entries to fetch per request
|
|
398
|
+
:param sort: Sorting parameter (optional)
|
|
399
|
+
:return: URL with pagination parameters added
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
params = {
|
|
403
|
+
"page": str(page),
|
|
404
|
+
"page_entries": str(page_entries)
|
|
405
|
+
}
|
|
406
|
+
if sort is not None:
|
|
407
|
+
params["sort"] = str(sort)
|
|
408
|
+
|
|
409
|
+
return self._add_params(url, params)
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def get_id_from_href(cls, href: str) -> str:
|
|
413
|
+
"""Extracts the identifier from the href and returns it
|
|
414
|
+
|
|
415
|
+
:param href: HAL href for a specific resource
|
|
416
|
+
:type href: string (valid URL)
|
|
417
|
+
:return: the id part of the href
|
|
418
|
+
:rtype: string
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
pos = href.rfind("/")
|
|
422
|
+
identifier = href[(pos + 1):]
|
|
423
|
+
return identifier
|
|
424
|
+
|
|
425
|
+
def _upload_resource_file(self, upload_file: str,
|
|
426
|
+
upload_type: str = "SOURCE",
|
|
427
|
+
upload_comment: str = "") -> Dict[str, str]:
|
|
428
|
+
"""Upload `upload_file` as attachment to SW360 which can then be used
|
|
429
|
+
by various resources as attachment content.
|
|
430
|
+
`upload_type` can be:
|
|
431
|
+
"DOCUMENT"
|
|
432
|
+
"SOURCE"
|
|
433
|
+
"CLEARING_REPORT"
|
|
434
|
+
"COMPONENT_LICENSE_INFO_XML"
|
|
435
|
+
"SOURCE_SELF"
|
|
436
|
+
"BINARY"
|
|
437
|
+
"BINARY_SELF"
|
|
438
|
+
"LICENSE_AGREEMENT"
|
|
439
|
+
"README_OSS"
|
|
440
|
+
|
|
441
|
+
API endpoint: POST /attachments
|
|
442
|
+
|
|
443
|
+
:return: Returns the attachment content of the uploaded file which
|
|
444
|
+
can be used by other resources.
|
|
445
|
+
:raises SW360Error: if unable to extract content from the upload
|
|
446
|
+
"""
|
|
447
|
+
if not os.path.exists(upload_file):
|
|
448
|
+
raise SW360Error(message="ERROR: file not found: " + upload_file)
|
|
449
|
+
|
|
450
|
+
filename = os.path.basename(upload_file)
|
|
451
|
+
url = self.url + "resource/api/attachments"
|
|
452
|
+
attachment_data = {
|
|
453
|
+
"filename": filename,
|
|
454
|
+
"createdComment": upload_comment,
|
|
455
|
+
"attachmentType": upload_type
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
file_data = {
|
|
459
|
+
"files": (filename, open(upload_file, "rb"), "multipart/form-data"),
|
|
460
|
+
"attachment": (
|
|
461
|
+
"", # dummy filename
|
|
462
|
+
json.dumps(attachment_data),
|
|
463
|
+
"application/json",
|
|
464
|
+
),
|
|
465
|
+
}
|
|
466
|
+
response = self.api_post_multipart(url, files=file_data)
|
|
467
|
+
attachment_content = None
|
|
468
|
+
if response is not None:
|
|
469
|
+
if response.status_code == HTTPStatus.ACCEPTED:
|
|
470
|
+
logger.warning(
|
|
471
|
+
f"Attachment upload was accepted by {url} but might not be visible yet: {response.text}"
|
|
472
|
+
)
|
|
473
|
+
if response.status_code == HTTPStatus.OK:
|
|
474
|
+
r = response.json()
|
|
475
|
+
if '_embedded' in r and 'sw360:attachments' in r['_embedded'] \
|
|
476
|
+
and len(r['_embedded']['sw360:attachments']) == 1:
|
|
477
|
+
content = r['_embedded']['sw360:attachments'][0]
|
|
478
|
+
attachment_content = {
|
|
479
|
+
'attachmentContentId': content['attachmentContentId'],
|
|
480
|
+
'filename': content['filename'],
|
|
481
|
+
'sha1': content['sha1'],
|
|
482
|
+
'attachmentType': content['attachmentType'],
|
|
483
|
+
'createdComment': content['createdComment'],
|
|
484
|
+
'checkStatus': content['checkStatus']
|
|
485
|
+
}
|
|
486
|
+
if not response.ok:
|
|
487
|
+
raise SW360Error(response, url)
|
|
488
|
+
if attachment_content is None:
|
|
489
|
+
raise SW360Error(response, url,
|
|
490
|
+
"Unable to fetch attachment content from the response.")
|
|
491
|
+
return attachment_content
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def _get_attachments(
|
|
495
|
+
resource: Optional[Dict[str, Any]]
|
|
496
|
+
) -> List[Dict[str, Any]]:
|
|
497
|
+
attachments: List[Dict[str, Any]] = []
|
|
498
|
+
if resource is None:
|
|
499
|
+
return attachments
|
|
500
|
+
if 'attachments' in resource:
|
|
501
|
+
attachments.extend(resource['attachments'])
|
|
502
|
+
if '_embedded' in resource and \
|
|
503
|
+
'sw360:attachments' in resource['_embedded']:
|
|
504
|
+
attachments.extend(resource['_embedded']['sw360:attachments'])
|
|
505
|
+
# Remove meta properties like "_links" from attachments
|
|
506
|
+
for attachment in attachments:
|
|
507
|
+
if '_links' in attachment:
|
|
508
|
+
del attachment['_links']
|
|
509
|
+
return attachments
|
|
510
|
+
|
|
511
|
+
def is_above_version_18(self) -> bool:
|
|
512
|
+
"""Check if API version is above version 18.
|
|
513
|
+
Used to check version >= 19 features."""
|
|
514
|
+
return self.api_version >= (19, 0)
|
|
515
|
+
|
|
516
|
+
def is_above_version_19(self) -> bool:
|
|
517
|
+
"""Check if API version is above version 19.
|
|
518
|
+
Used to check version >= 20 features."""
|
|
519
|
+
return self.api_version >= (20, 0)
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def get_linked_id(cls, data: Dict[str, Any], link_key: str = "self") -> Optional[str]:
|
|
523
|
+
"""Extract the resource ID from a HAL ``_links`` entry.
|
|
524
|
+
|
|
525
|
+
Looks up ``data["_links"][link_key]["href"]`` and returns the last
|
|
526
|
+
path segment, which is the resource identifier in the SW360 REST API.
|
|
527
|
+
|
|
528
|
+
It defaults to the "self" link, but can be used for any link relation key
|
|
529
|
+
like "sw360:component" or "sw360:project". The "sw360:" prefix can be
|
|
530
|
+
omitted in the `link_key` argument.
|
|
531
|
+
|
|
532
|
+
:param data: a JSON response dict containing a ``_links`` section
|
|
533
|
+
:param link_key: the link relation key, e.g. ``"self"`` or
|
|
534
|
+
``"sw360:component"``
|
|
535
|
+
:return: the extracted resource ID, or ``None`` if the path does not
|
|
536
|
+
exist
|
|
537
|
+
:rtype: Optional[str]
|
|
538
|
+
"""
|
|
539
|
+
links = data.get("_links")
|
|
540
|
+
if not isinstance(links, dict):
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
if link_key in links:
|
|
544
|
+
entry = links.get(link_key)
|
|
545
|
+
else:
|
|
546
|
+
entry = links.get("sw360:" + link_key)
|
|
547
|
+
if not isinstance(entry, dict):
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
href = entry.get("href")
|
|
551
|
+
if not isinstance(href, str):
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
return cls.get_id_from_href(href)
|
|
555
|
+
|
|
556
|
+
@classmethod
|
|
557
|
+
def get_embedded(cls, data: Dict[str, Any], key: str) -> List[Dict[str, Any]]:
|
|
558
|
+
"""Safely retrieve an embedded resource list from a HAL response.
|
|
559
|
+
|
|
560
|
+
Returns ``data["_embedded"][key]`` if it exists and is a list,
|
|
561
|
+
otherwise returns an empty list. The "sw360:" prefix can be omitted
|
|
562
|
+
in the `key` argument.
|
|
563
|
+
|
|
564
|
+
:param data: a JSON response dict potentially containing an
|
|
565
|
+
``_embedded`` section
|
|
566
|
+
:param key: the embedded resource key, e.g. ``"sw360:releases"``
|
|
567
|
+
:return: the list of embedded resources, or ``[]`` if not present
|
|
568
|
+
:rtype: List[Dict[str, Any]]
|
|
569
|
+
"""
|
|
570
|
+
embedded = data.get("_embedded")
|
|
571
|
+
if not isinstance(embedded, dict):
|
|
572
|
+
return []
|
|
573
|
+
|
|
574
|
+
if key in embedded:
|
|
575
|
+
items = embedded.get(key)
|
|
576
|
+
else:
|
|
577
|
+
items = embedded.get("sw360:" + key)
|
|
578
|
+
if not isinstance(items, list):
|
|
579
|
+
return []
|
|
580
|
+
|
|
581
|
+
return items
|
|
@@ -39,8 +39,8 @@ class ClearingMixin(BaseMixin):
|
|
|
39
39
|
|
|
40
40
|
API endpoint: GET /clearingrequest/project/{id}
|
|
41
41
|
|
|
42
|
-
:param
|
|
43
|
-
:type
|
|
42
|
+
:param project_id: the id of the Project to fetch clearing requests for
|
|
43
|
+
:type project_id: string
|
|
44
44
|
:return: a clearing request
|
|
45
45
|
:rtype: JSON clearing request object
|
|
46
46
|
:raises SW360Error: if there is a negative HTTP response
|