Skip to content

Commit 02cdd62

Browse files
authored
Merge pull request #54 from sw360/links-and-embedded-parsing
Convenience functions for HAL _embedded and _links parsing
2 parents 54cf2d9 + 80e8416 commit 02cdd62

4 files changed

Lines changed: 351 additions & 13 deletions

File tree

‎ChangeLog.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55

66
# SW360 Base Library for Python
77

8+
## NEXT
9+
10+
* new helper functions `get_linked_id()` and `get_embedded()` to help parsing the
11+
`obj["_links"]` and `obj["_embedded"]` sections of HAL responses.
12+
* All `get_*()` and `update_*()` methods now return a `SW360Response` which is a dict
13+
subclass. So you can still use the result as a normal dict, existing code should work
14+
unchanged. The new class however also provides the convenience methods `linked_id()`,
15+
`linked_ids()`, `embedded_list()`, and `embedded_lists()` to easily access the HAL
16+
sections of the response.
17+
818
## V1.12.0.dev1
919

1020
* Support Keycloak (OpenID) token generation.

‎sw360/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
__version__ = (1, 11, 2)
1111

12+
from .base import SW360Response
1213
from .sw360_api import SW360
1314
from .sw360error import SW360Error
1415
from .sw360keycloak import SW360Keycloak
@@ -18,5 +19,6 @@
1819
"SW360",
1920
"SW360Error",
2021
"SW360OAuth2",
21-
"SW360Keycloak"
22+
"SW360Keycloak",
23+
"SW360Response",
2224
]

‎sw360/base.py‎

Lines changed: 150 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# -------------------------------------------------------------------------------
2-
# Copyright (c) 2023-2025 Siemens
2+
# Copyright (c) 2023-2026 Siemens
33
# All Rights Reserved.
44
# Authors: thomas.graf@siemens.com
5+
# Authors: gernot.hillier@siemens.com
56
#
67
# Licensed under the MIT license.
78
# SPDX-License-Identifier: MIT
@@ -15,6 +16,76 @@
1516
from .sw360error import SW360Error
1617

1718

19+
class SW360Response(Dict[str, Any]):
20+
"""A regular dict response with convenience methods for HAL traversal.
21+
22+
Behaves identically to a plain ``dict`` for backward compatibility, but
23+
adds ``linked_id()``, ``linked_ids()``, ``embedded_list()``, and
24+
``embedded_lists()`` for comfortable access to ``_links`` and
25+
``_embedded`` sections.
26+
"""
27+
28+
def linked_id(self, link_key: str = "self") -> Optional[str]:
29+
"""Extract a resource ID from a ``_links`` entry.
30+
31+
Convenience shortcut for ``BaseMixin.get_linked_id(self, link_key)``.
32+
33+
:param link_key: the link relation key, defaults to ``"self"``
34+
:return: the resource ID, or ``None`` if not available
35+
"""
36+
return BaseMixin.get_linked_id(self, link_key)
37+
38+
def linked_ids(self) -> Dict[str, str]:
39+
"""Return all linked resource IDs as a dict.
40+
41+
Iterates all entries in ``_links`` and resolves each href to its
42+
resource ID, returning a mapping of link key to ID. Useful for
43+
inspecting which linked resources are available.
44+
45+
:return: dict mapping each link key to its resource ID
46+
"""
47+
links = self.get("_links")
48+
if not isinstance(links, dict):
49+
return {}
50+
return {
51+
key: BaseMixin.get_id_from_href(entry["href"])
52+
for key, entry in links.items()
53+
if isinstance(entry, dict) and isinstance(entry.get("href"), str)
54+
}
55+
56+
def embedded_list(self, key: str) -> List["SW360Response"]:
57+
"""Retrieve a specific embedded resource list.
58+
59+
Like ``BaseMixin.get_embedded()``, but each item in the returned
60+
list is wrapped in ``SW360Response`` so that ``.linked_id()`` and
61+
other convenience methods are available on the items.
62+
63+
:param key: the embedded resource key, e.g. ``"releases"``
64+
:return: list of embedded resources as ``SW360Response`` objects,
65+
or ``[]`` if not present
66+
"""
67+
return [SW360Response(item) for item in BaseMixin.get_embedded(self, key)]
68+
69+
def embedded_lists(self) -> Dict[str, List["SW360Response"]]:
70+
"""Return all embedded resource lists as a dict.
71+
72+
Iterates all entries in ``_embedded`` and wraps each item in
73+
``SW360Response``. Useful for inspecting which embedded resources
74+
are available.
75+
76+
:return: dict mapping each embedded key to its list of
77+
``SW360Response`` objects
78+
"""
79+
embedded = self.get("_embedded")
80+
if not isinstance(embedded, dict):
81+
return {}
82+
return {
83+
key: [SW360Response(item) for item in items]
84+
for key, items in embedded.items()
85+
if isinstance(items, list)
86+
}
87+
88+
1889
class BaseMixin():
1990
"""Python interface to the Siemens SW360 platform
2091
@@ -48,13 +119,13 @@ def __init__(self, url: str, token: str, oauth2: bool = False) -> None:
48119

49120
self.force_no_session = False
50121

51-
def api_get(self, url: str = "") -> Optional[Dict[str, Any]]:
122+
def api_get(self, url: str = "") -> Optional[SW360Response]:
52123
"""Request `url` from REST API and return json answer.
53124
54125
:param url: the url to be requested
55126
:type url: string
56-
:return: JSON data
57-
:rtype: JSON
127+
:return: JSON data as dict with convenience methods for HAL traversal
128+
:rtype: SW360Response
58129
:raises SW360Error: if there is a negative HTTP response
59130
"""
60131

@@ -70,7 +141,11 @@ def api_get(self, url: str = "") -> Optional[Dict[str, Any]]:
70141
if response.ok:
71142
if response.status_code == 204: # 204 = no content
72143
return None
73-
return response.json()
144+
parsed = response.json()
145+
if isinstance(parsed, dict):
146+
return SW360Response(parsed)
147+
else:
148+
return parsed
74149

75150
raise SW360Error(response, url)
76151

@@ -137,16 +212,16 @@ def api_post(
137212

138213
raise SW360Error(response, url)
139214

140-
def api_patch(self, url: str = "", json: Any = {}) -> Optional[Dict[str, Any]]:
215+
def api_patch(self, url: str = "", json: Any = {}) -> Optional[SW360Response]:
141216
"""
142217
Send a PATCH request to the specified URL with the provided json data.
143218
144219
:param url: The URL to send the PATCH request to.
145220
:type url: str
146221
:param json: The dictionary containing json data to be sent in the request.
147222
:type json: Dict[str, Any]
148-
:return: The JSON response received from the server, if any.
149-
:rtype: Optional[Dict[str, Any]]
223+
:return: The JSON response as dict with convenience methods for HAL traversal
224+
:rtype: Optional[SW360Response]
150225
:raises SW360Error: If the HTTP response indicates an error.
151226
"""
152227
if (not self.force_no_session) and self.session is None:
@@ -162,7 +237,11 @@ def api_patch(self, url: str = "", json: Any = {}) -> Optional[Dict[str, Any]]:
162237
if response.status_code == 204: # 204 = no content
163238
return None
164239
if response.content:
165-
return response.json()
240+
parsed = response.json()
241+
if isinstance(parsed, dict):
242+
return SW360Response(parsed)
243+
else:
244+
return parsed
166245
else:
167246
return None
168247

@@ -241,3 +320,65 @@ def get_id_from_href(cls, href: str) -> str:
241320
pos = href.rfind("/")
242321
identifier = href[(pos + 1):]
243322
return identifier
323+
324+
@classmethod
325+
def get_linked_id(cls, data: Dict[str, Any], link_key: str = "self") -> Optional[str]:
326+
"""Extract the resource ID from a HAL ``_links`` entry.
327+
328+
Looks up ``data["_links"][link_key]["href"]`` and returns the last
329+
path segment, which is the resource identifier in the SW360 REST API.
330+
331+
It defaults to the "self" link, but can be used for any link relation key
332+
like "sw360:component" or "sw360:project". The "sw360:" prefix can be
333+
omitted in the `link_key` argument.
334+
335+
:param data: a JSON response dict containing a ``_links`` section
336+
:param link_key: the link relation key, e.g. ``"self"`` or
337+
``"sw360:component"``
338+
:return: the extracted resource ID, or ``None`` if the path does not
339+
exist
340+
:rtype: Optional[str]
341+
"""
342+
links = data.get("_links")
343+
if not isinstance(links, dict):
344+
return None
345+
346+
if link_key in links:
347+
entry = links.get(link_key)
348+
else:
349+
entry = links.get("sw360:" + link_key)
350+
if not isinstance(entry, dict):
351+
return None
352+
353+
href = entry.get("href")
354+
if not isinstance(href, str):
355+
return None
356+
357+
return cls.get_id_from_href(href)
358+
359+
@classmethod
360+
def get_embedded(cls, data: Dict[str, Any], key: str) -> List[Dict[str, Any]]:
361+
"""Safely retrieve an embedded resource list from a HAL response.
362+
363+
Returns ``data["_embedded"][key]`` if it exists and is a list,
364+
otherwise returns an empty list. The "sw360:" prefix can be omitted
365+
in the `key` argument.
366+
367+
:param data: a JSON response dict potentially containing an
368+
``_embedded`` section
369+
:param key: the embedded resource key, e.g. ``"sw360:releases"``
370+
:return: the list of embedded resources, or ``[]`` if not present
371+
:rtype: List[Dict[str, Any]]
372+
"""
373+
embedded = data.get("_embedded")
374+
if not isinstance(embedded, dict):
375+
return []
376+
377+
if key in embedded:
378+
items = embedded.get(key)
379+
else:
380+
items = embedded.get("sw360:" + key)
381+
if not isinstance(items, list):
382+
return []
383+
384+
return items

0 commit comments

Comments
 (0)