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.
Files changed (27) hide show
  1. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/PKG-INFO +1 -1
  2. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/pyproject.toml +1 -1
  3. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/__init__.py +11 -3
  4. sw360-1.12.0.dev2/sw360/base.py +581 -0
  5. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/clearing.py +2 -2
  6. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/components.py +128 -76
  7. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/license.py +40 -12
  8. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/moderationrequests.py +44 -27
  9. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/packages.py +133 -48
  10. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/project.py +216 -88
  11. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/releases.py +126 -37
  12. sw360-1.12.0.dev2/sw360/reports.py +188 -0
  13. sw360-1.12.0.dev2/sw360/sorting.py +94 -0
  14. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360_api.py +43 -6
  15. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360error.py +12 -0
  16. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360keycloak.py +9 -11
  17. sw360-1.12.0.dev2/sw360/vendor.py +196 -0
  18. sw360-1.12.0.dev2/sw360/vulnerabilities.py +80 -0
  19. sw360-1.12.0.dev1/sw360/base.py +0 -243
  20. sw360-1.12.0.dev1/sw360/vendor.py +0 -113
  21. sw360-1.12.0.dev1/sw360/vulnerabilities.py +0 -44
  22. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/LICENSES/MIT.txt +0 -0
  23. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/License.md +0 -0
  24. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/Readme.md +0 -0
  25. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/attachments.py +0 -0
  26. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/py.typed +0 -0
  27. {sw360-1.12.0.dev1 → sw360-1.12.0.dev2}/sw360/sw360oauth2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sw360
3
- Version: 1.12.0.dev1
3
+ Version: 1.12.0.dev2
4
4
  Summary: Python interface to the SW360 software component catalogue
5
5
  License: MIT
6
6
  License-File: LICENSES/MIT.txt
@@ -3,7 +3,7 @@
3
3
 
4
4
  [tool.poetry]
5
5
  name = "sw360"
6
- version = "1.12.0.dev1"
6
+ version = "1.12.0.dev2"
7
7
  description = "Python interface to the SW360 software component catalogue"
8
8
  authors = ["Thomas Graf <thomas.graf@siemens.com>",
9
9
  "Gernot Hillier <gernot.hillier@siemens.com>"]
@@ -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, 11, 2)
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 request_id: the id of the clearing request to be requested
43
- :type request_id: string
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