From 4ea51051247e41bae45b1be4fd607cb6e15ffbd7 Mon Sep 17 00:00:00 2001 From: Gernot Hillier Date: Mon, 8 Jun 2026 17:14:38 +0200 Subject: [PATCH 1/2] feat: new helper functions get_linked_id and get_embedded --- ChangeLog.md | 5 +++ sw360/base.py | 65 +++++++++++++++++++++++++++++- tests/test_sw360_support.py | 80 ++++++++++++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 36315ab..cd6f840 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,11 @@ # SW360 Base Library for Python +## NEXT + +* new helper functions `get_linked_id()` and `get_embedded()` to help parsing the + `obj["_links"]` and `obj["_embedded"]` sections of HAL responses. + ## V1.12.0.dev1 * Support Keycloak (OpenID) token generation. diff --git a/sw360/base.py b/sw360/base.py index 4856b3b..23d30c3 100644 --- a/sw360/base.py +++ b/sw360/base.py @@ -1,7 +1,8 @@ # ------------------------------------------------------------------------------- -# Copyright (c) 2023-2025 Siemens +# Copyright (c) 2023-2026 Siemens # All Rights Reserved. # Authors: thomas.graf@siemens.com +# Authors: gernot.hillier@siemens.com # # Licensed under the MIT license. # SPDX-License-Identifier: MIT @@ -241,3 +242,65 @@ def get_id_from_href(cls, href: str) -> str: pos = href.rfind("/") identifier = href[(pos + 1):] return identifier + + @classmethod + def get_linked_id(cls, data: Dict[str, Any], link_key: str = "self") -> Optional[str]: + """Extract the resource ID from a HAL ``_links`` entry. + + Looks up ``data["_links"][link_key]["href"]`` and returns the last + path segment, which is the resource identifier in the SW360 REST API. + + It defaults to the "self" link, but can be used for any link relation key + like "sw360:component" or "sw360:project". The "sw360:" prefix can be + omitted in the `link_key` argument. + + :param data: a JSON response dict containing a ``_links`` section + :param link_key: the link relation key, e.g. ``"self"`` or + ``"sw360:component"`` + :return: the extracted resource ID, or ``None`` if the path does not + exist + :rtype: Optional[str] + """ + links = data.get("_links") + if not isinstance(links, dict): + return None + + if link_key in links: + entry = links.get(link_key) + else: + entry = links.get("sw360:" + link_key) + if not isinstance(entry, dict): + return None + + href = entry.get("href") + if not isinstance(href, str): + return None + + return cls.get_id_from_href(href) + + @classmethod + def get_embedded(cls, data: Dict[str, Any], key: str) -> List[Dict[str, Any]]: + """Safely retrieve an embedded resource list from a HAL response. + + Returns ``data["_embedded"][key]`` if it exists and is a list, + otherwise returns an empty list. The "sw360:" prefix can be omitted + in the `key` argument. + + :param data: a JSON response dict potentially containing an + ``_embedded`` section + :param key: the embedded resource key, e.g. ``"sw360:releases"`` + :return: the list of embedded resources, or ``[]`` if not present + :rtype: List[Dict[str, Any]] + """ + embedded = data.get("_embedded") + if not isinstance(embedded, dict): + return [] + + if key in embedded: + items = embedded.get(key) + else: + items = embedded.get("sw360:" + key) + if not isinstance(items, list): + return [] + + return items diff --git a/tests/test_sw360_support.py b/tests/test_sw360_support.py index ba0e985..50cadba 100644 --- a/tests/test_sw360_support.py +++ b/tests/test_sw360_support.py @@ -1,7 +1,8 @@ # ------------------------------------------------------------------------------- -# Copyright (c) 2020 Siemens +# Copyright (c) 2020-2026 Siemens # All Rights Reserved. -# Author: thomas.graf@siemens.com +# Authors: thomas.graf@siemens.com +# Authors: gernot.hillier@siemens.com # # Licensed under the MIT license. # SPDX-License-Identifier: MIT @@ -27,6 +28,81 @@ def test_get_id_from_href(self) -> None: actual = lib.get_id_from_href(URL) self.assertEqual("00dc0db789f9372ed6bcfd55f100e3ce", actual) + def test_get_linked_id_missing_key(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = { + "_links": { + "self": { + "href": "https://sw360.example.com/api/releases/abc123" + } + } + } + self.assertIsNone(lib.get_linked_id(data, "sw360:component")) + + def test_get_linked_id_multiple_links(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = { + "_links": { + "self": { + "href": "https://sw360.example.com/api/releases/rel001" + }, + "sw360:component": { + "href": "https://sw360.example.com/api/components/comp42" + }, + "sw360:project": { + "href": "https://sw360.example.com/api/projects/proj99" + } + } + } + self.assertEqual("rel001", lib.get_linked_id(data, "self")) + self.assertEqual("comp42", lib.get_linked_id(data, "sw360:component")) + self.assertEqual("proj99", lib.get_linked_id(data, "project")) + + def test_get_self_id(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = { + "_links": { + "self": { + "href": "https://sw360.example.com/api/releases/abc123" + } + } + } + self.assertEqual("abc123", lib.get_linked_id(data)) + + def test_get_embedded_releases(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = { + "_embedded": { + "sw360:releases": [ + {"name": "acl", "version": "1.4"}, + {"name": "zlib", "version": "1.2.11"} + ] + } + } + result = lib.get_embedded(data, "sw360:releases") + self.assertEqual(2, len(result)) + self.assertEqual("zlib", result[1]["name"]) + + result = lib.get_embedded(data, "releases") + self.assertEqual(2, len(result)) + self.assertEqual("zlib", result[1]["name"]) + + def test_get_embedded_missing_section(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = {"name": "My Project"} + self.assertEqual([], lib.get_embedded(data, "sw360:releases")) + + def test_get_embedded_missing_key(self) -> None: + lib = SW360(self.MYURL, self.MYTOKEN, False) + data = { + "_embedded": { + "sw360:components": [ + {"name": "curl"} + ] + } + } + self.assertEqual([], lib.get_embedded(data, "sw360:releases")) + if __name__ == "__main__": unittest.main() From 80e8416a1d095137fab31a1df41d90928b5ea6b3 Mon Sep 17 00:00:00 2001 From: Gernot Hillier Date: Mon, 8 Jun 2026 21:33:34 +0200 Subject: [PATCH 2/2] feat: dict wrapper class SW360Response with HAL access methods --- ChangeLog.md | 5 ++ sw360/__init__.py | 4 +- sw360/base.py | 94 +++++++++++++++++++++++++++--- tests/test_sw360_support.py | 111 +++++++++++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 10 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index cd6f840..ba730f3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -9,6 +9,11 @@ * new helper functions `get_linked_id()` and `get_embedded()` to help parsing the `obj["_links"]` and `obj["_embedded"]` sections of HAL responses. +* All `get_*()` and `update_*()` methods now return a `SW360Response` which is a dict + subclass. So you can still use the result as a normal dict, existing code should work + unchanged. The new class however also provides the convenience methods `linked_id()`, + `linked_ids()`, `embedded_list()`, and `embedded_lists()` to easily access the HAL + sections of the response. ## V1.12.0.dev1 diff --git a/sw360/__init__.py b/sw360/__init__.py index ecd246e..ca7de65 100644 --- a/sw360/__init__.py +++ b/sw360/__init__.py @@ -9,6 +9,7 @@ __version__ = (1, 11, 2) +from .base import SW360Response from .sw360_api import SW360 from .sw360error import SW360Error from .sw360keycloak import SW360Keycloak @@ -18,5 +19,6 @@ "SW360", "SW360Error", "SW360OAuth2", - "SW360Keycloak" + "SW360Keycloak", + "SW360Response", ] diff --git a/sw360/base.py b/sw360/base.py index 23d30c3..02f9d64 100644 --- a/sw360/base.py +++ b/sw360/base.py @@ -16,6 +16,76 @@ from .sw360error import SW360Error +class SW360Response(Dict[str, Any]): + """A regular dict response with convenience methods for HAL traversal. + + Behaves identically to a plain ``dict`` for backward compatibility, but + adds ``linked_id()``, ``linked_ids()``, ``embedded_list()``, and + ``embedded_lists()`` for comfortable access to ``_links`` and + ``_embedded`` sections. + """ + + def linked_id(self, link_key: str = "self") -> Optional[str]: + """Extract a resource ID from a ``_links`` entry. + + Convenience shortcut for ``BaseMixin.get_linked_id(self, link_key)``. + + :param link_key: the link relation key, defaults to ``"self"`` + :return: the resource ID, or ``None`` if not available + """ + return BaseMixin.get_linked_id(self, link_key) + + def linked_ids(self) -> Dict[str, str]: + """Return all linked resource IDs as a dict. + + Iterates all entries in ``_links`` and resolves each href to its + resource ID, returning a mapping of link key to ID. Useful for + inspecting which linked resources are available. + + :return: dict mapping each link key to its resource ID + """ + links = self.get("_links") + if not isinstance(links, dict): + return {} + return { + key: BaseMixin.get_id_from_href(entry["href"]) + for key, entry in links.items() + if isinstance(entry, dict) and isinstance(entry.get("href"), str) + } + + def embedded_list(self, key: str) -> List["SW360Response"]: + """Retrieve a specific embedded resource list. + + Like ``BaseMixin.get_embedded()``, but each item in the returned + list is wrapped in ``SW360Response`` so that ``.linked_id()`` and + other convenience methods are available on the items. + + :param key: the embedded resource key, e.g. ``"releases"`` + :return: list of embedded resources as ``SW360Response`` objects, + or ``[]`` if not present + """ + return [SW360Response(item) for item in BaseMixin.get_embedded(self, key)] + + def embedded_lists(self) -> Dict[str, List["SW360Response"]]: + """Return all embedded resource lists as a dict. + + Iterates all entries in ``_embedded`` and wraps each item in + ``SW360Response``. Useful for inspecting which embedded resources + are available. + + :return: dict mapping each embedded key to its list of + ``SW360Response`` objects + """ + embedded = self.get("_embedded") + if not isinstance(embedded, dict): + return {} + return { + key: [SW360Response(item) for item in items] + for key, items in embedded.items() + if isinstance(items, list) + } + + class BaseMixin(): """Python interface to the Siemens SW360 platform @@ -49,13 +119,13 @@ def __init__(self, url: str, token: str, oauth2: bool = False) -> None: self.force_no_session = False - def api_get(self, url: str = "") -> Optional[Dict[str, Any]]: + def api_get(self, url: str = "") -> Optional[SW360Response]: """Request `url` from REST API and return json answer. :param url: the url to be requested :type url: string - :return: JSON data - :rtype: JSON + :return: JSON data as dict with convenience methods for HAL traversal + :rtype: SW360Response :raises SW360Error: if there is a negative HTTP response """ @@ -71,7 +141,11 @@ def api_get(self, url: str = "") -> Optional[Dict[str, Any]]: if response.ok: if response.status_code == 204: # 204 = no content return None - return response.json() + parsed = response.json() + if isinstance(parsed, dict): + return SW360Response(parsed) + else: + return parsed raise SW360Error(response, url) @@ -138,7 +212,7 @@ def api_post( raise SW360Error(response, url) - def api_patch(self, url: str = "", json: Any = {}) -> Optional[Dict[str, Any]]: + def api_patch(self, url: str = "", json: Any = {}) -> Optional[SW360Response]: """ Send a PATCH request to the specified URL with the provided json data. @@ -146,8 +220,8 @@ def api_patch(self, url: str = "", json: Any = {}) -> Optional[Dict[str, Any]]: :type url: str :param json: The dictionary containing json data to be sent in the request. :type json: Dict[str, Any] - :return: The JSON response received from the server, if any. - :rtype: Optional[Dict[str, Any]] + :return: The JSON response as dict with convenience methods for HAL traversal + :rtype: Optional[SW360Response] :raises SW360Error: If the HTTP response indicates an error. """ if (not self.force_no_session) and self.session is None: @@ -163,7 +237,11 @@ def api_patch(self, url: str = "", json: Any = {}) -> Optional[Dict[str, Any]]: if response.status_code == 204: # 204 = no content return None if response.content: - return response.json() + parsed = response.json() + if isinstance(parsed, dict): + return SW360Response(parsed) + else: + return parsed else: return None diff --git a/tests/test_sw360_support.py b/tests/test_sw360_support.py index 50cadba..6fd9d97 100644 --- a/tests/test_sw360_support.py +++ b/tests/test_sw360_support.py @@ -13,7 +13,7 @@ sys.path.insert(1, "..") -from sw360 import SW360 # noqa: E402 +from sw360 import SW360, SW360Response # noqa: E402 class Sw360TestSupportMethods(unittest.TestCase): @@ -104,5 +104,114 @@ def test_get_embedded_missing_key(self) -> None: self.assertEqual([], lib.get_embedded(data, "sw360:releases")) +class Sw360ResponseTest(unittest.TestCase): + + def test_is_still_a_dict(self) -> None: + resp = SW360Response({"name": "acl", "version": "1.4"}) + self.assertEqual("acl", resp["name"]) + self.assertEqual("1.4", resp["version"]) + self.assertTrue(isinstance(resp, dict)) + + def test_linked_id_defaults_to_self(self) -> None: + resp = SW360Response({ + "_links": { + "self": { + "href": "https://sw360.example.com/api/releases/abc123" + } + } + }) + self.assertEqual("abc123", resp.linked_id()) + + def test_linked_id_with_key(self) -> None: + resp = SW360Response({ + "_links": { + "sw360:component": { + "href": "https://sw360.example.com/api/components/7b4" + } + } + }) + self.assertEqual("7b4", resp.linked_id("sw360:component")) + self.assertEqual("7b4", resp.linked_id("component")) + + def test_linked_id_missing_links(self) -> None: + resp = SW360Response({"name": "acl"}) + self.assertIsNone(resp.linked_id()) + + def test_linked_ids(self) -> None: + resp = SW360Response({ + "_links": { + "self": { + "href": "https://sw360.example.com/api/releases/abc123" + }, + "sw360:component": { + "href": "https://sw360.example.com/api/components/7b4" + } + } + }) + ids = resp.linked_ids() + self.assertEqual("abc123", ids["self"]) + self.assertEqual("7b4", ids["sw360:component"]) + self.assertEqual(2, len(ids)) + + def test_linked_ids_empty_when_no_links(self) -> None: + resp = SW360Response({"name": "acl"}) + self.assertEqual({}, resp.linked_ids()) + + def test_embedded_list(self) -> None: + resp = SW360Response({ + "_embedded": { + "sw360:releases": [ + {"name": "acl", "version": "1.4", + "_links": {"self": {"href": "https://sw360.example.com/api/releases/r1"}}} + ] + } + }) + result = resp.embedded_list("releases") + self.assertEqual(1, len(result)) + self.assertEqual("acl", result[0]["name"]) + self.assertIsInstance(result[0], SW360Response) + self.assertEqual("r1", result[0].linked_id()) + + def test_embedded_list_with_prefixed_key(self) -> None: + resp = SW360Response({ + "_embedded": { + "sw360:releases": [ + {"name": "acl"} + ] + } + }) + self.assertEqual(1, len(resp.embedded_list("sw360:releases"))) + + def test_embedded_list_empty_when_missing(self) -> None: + resp = SW360Response({"name": "My Project"}) + self.assertEqual([], resp.embedded_list("releases")) + + def test_embedded_lists(self) -> None: + resp = SW360Response({ + "_embedded": { + "sw360:releases": [ + {"name": "acl", + "_links": {"self": {"href": "https://sw360.example.com/api/releases/r1"}}} + ], + "sw360:attachments": [ + {"filename": "source.tar.gz", + "_links": {"self": {"href": "https://sw360.example.com/api/attachments/a1"}}} + ] + } + }) + lists = resp.embedded_lists() + self.assertEqual(2, len(lists)) + self.assertIn("sw360:releases", lists) + self.assertIn("sw360:attachments", lists) + self.assertIsInstance(lists["sw360:releases"][0], SW360Response) + self.assertEqual("r1", lists["sw360:releases"][0].linked_id()) + self.assertIsInstance(lists["sw360:attachments"][0], SW360Response) + self.assertEqual("a1", lists["sw360:attachments"][0].linked_id()) + + def test_embedded_lists_empty_when_no_embedded(self) -> None: + resp = SW360Response({"name": "My Project"}) + self.assertEqual({}, resp.embedded_lists()) + + if __name__ == "__main__": unittest.main()