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
1516from .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+
1889class 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