From 49c1f4343e53b16f7a196094aa65db68aa4a3a71 Mon Sep 17 00:00:00 2001 From: RAJVEER42 Date: Sat, 4 Jul 2026 10:19:15 +0530 Subject: [PATCH] feat(folders): wrap folder contents list and unlink endpoints Refs #52. Adds two Folders methods: - list_folder_contents() -> GET /folders/{id}/contents, returning a list[FolderContent] (id / content / removable) - unlink_folder_content() -> PUT /folders/contents/{contentId}/unlink Adds the FolderContent model to obj.py following the existing from_json convention. list_folder_contents() raises AuthorizationError on HTTP 403, consistent with the other folder endpoints. Verified against Fossology 4.4.0 (API 1.6.1) running in a container: all 5 tests pass (live listing, mocked 403/404 for the listing, and mocked success/404 for the unlink). Signed-off-by: RAJVEER42 --- fossology/folders.py | 53 ++++++++++++++++++++++++++++++++-- fossology/obj.py | 17 +++++++++++ tests/test_folders.py | 66 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/fossology/folders.py b/fossology/folders.py index b6075ed..fae6e1f 100644 --- a/fossology/folders.py +++ b/fossology/folders.py @@ -5,7 +5,7 @@ import logging from fossology.exceptions import AuthorizationError, FossologyApiError -from fossology.obj import Folder +from fossology.obj import Folder, FolderContent logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -232,4 +232,53 @@ def move_folder(self, folder, parent): :rtype: Folder() object :raises FossologyApiError: if the REST call failed """ - return self._put_folder("move", folder, parent) \ No newline at end of file + return self._put_folder("move", folder, parent) + + def list_folder_contents(self, folder: Folder) -> list[FolderContent]: + """List the contents of a folder + + API Endpoint: GET /folders/{id}/contents + + :param folder: the folder to list the contents of + :type folder: Folder + :return: the list of contents (uploads and subfolders) of the folder + :rtype: list[FolderContent] + :raises FossologyApiError: if the REST call failed + :raises AuthorizationError: if the REST call is not authorized + """ + response = self.session.get(f"{self.api}/folders/{folder.id}/contents") + + if response.status_code == 200: + return [FolderContent.from_json(item) for item in response.json()] + elif response.status_code == 403: + description = f"Folder {folder.id} is not accessible" + raise AuthorizationError(description, response) + elif response.status_code == 404: + description = f"Folder {folder.id} does not exist" + raise FossologyApiError(description, response) + else: + description = f"Unable to get contents of folder {folder.name} (id={folder.id})" + raise FossologyApiError(description, response) + + def unlink_folder_content(self, content_id: int): + """Unlink a content from its folder + + API Endpoint: PUT /folders/contents/{contentId}/unlink + + :param content_id: the id of the folder content to unlink (see + :func:`~fossology.folders.Folders.list_folder_contents`) + :type content_id: int + :raises FossologyApiError: if the REST call failed + """ + response = self.session.put( + f"{self.api}/folders/contents/{content_id}/unlink" + ) + + if response.status_code == 200: + logger.info(f"Folder content {content_id} has been unlinked") + elif response.status_code == 404: + description = f"Folder content {content_id} does not exist" + raise FossologyApiError(description, response) + else: + description = f"Unable to unlink folder content {content_id}" + raise FossologyApiError(description, response) \ No newline at end of file diff --git a/fossology/obj.py b/fossology/obj.py index 89740f2..906a0ff 100644 --- a/fossology/obj.py +++ b/fossology/obj.py @@ -207,6 +207,23 @@ def from_json_v2(cls, json_dict): ) +class FolderContent(object): + """FOSSology folder content (an upload or a subfolder linked in a folder).""" + + def __init__(self, id=None, content=None, removable=None, **kwargs): + self.id = id + self.content = content + self.removable = removable + self.additional_info = kwargs + + def __str__(self): + return f"Folder content {self.content} ({self.id}), removable={self.removable}" + + @classmethod + def from_json(cls, json_dict): + return cls(**json_dict) + + class Findings(object): """FOSSology license findings.""" diff --git a/tests/test_folders.py b/tests/test_folders.py index 35a5822..8d86571 100644 --- a/tests/test_folders.py +++ b/tests/test_folders.py @@ -3,13 +3,14 @@ import secrets import time +from unittest.mock import MagicMock import pytest import responses from fossology import Fossology from fossology.exceptions import AuthorizationError, FossologyApiError -from fossology.obj import Folder +from fossology.obj import Folder, FolderContent @responses.activate @@ -201,3 +202,66 @@ def test_delete_folder_error(foss_server: str, foss: Fossology): with pytest.raises(FossologyApiError) as excinfo: foss.delete_folder(folder) assert f"Unable to delete folder {folder.id}" in str(excinfo.value) + + +def test_list_folder_contents(foss: Fossology): + name = "FolderContentsTest" + subfolder = foss.create_folder(foss.rootFolder, name, "list contents test") + contents = foss.list_folder_contents(foss.rootFolder) + assert isinstance(contents, list) + assert contents + assert all(isinstance(content, FolderContent) for content in contents) + match = [c for c in contents if c.content and name in c.content] + assert match, f"{name} not found in folder contents" + assert match[0].id is not None + assert "Folder content" in str(match[0]) + foss.delete_folder(subfolder) + + +@responses.activate +def test_list_folder_contents_unauthorized(foss_server: str, foss: Fossology): + responses.add( + responses.GET, + f"{foss_server}/api/v1/folders/{foss.rootFolder.id}/contents", + status=403, + ) + with pytest.raises(AuthorizationError) as excinfo: + foss.list_folder_contents(foss.rootFolder) + assert f"Folder {foss.rootFolder.id} is not accessible" in str(excinfo.value) + + +@responses.activate +def test_list_folder_contents_not_found(foss_server: str, foss: Fossology): + responses.add( + responses.GET, + f"{foss_server}/api/v1/folders/{foss.rootFolder.id}/contents", + status=404, + ) + with pytest.raises(FossologyApiError) as excinfo: + foss.list_folder_contents(foss.rootFolder) + assert f"Folder {foss.rootFolder.id} does not exist" in str(excinfo.value) + + +@responses.activate +def test_unlink_folder_content(foss_server: str, foss: Fossology, monkeypatch): + mocked_logger = MagicMock() + monkeypatch.setattr("fossology.folders.logger", mocked_logger) + responses.add( + responses.PUT, + f"{foss_server}/api/v1/folders/contents/42/unlink", + status=200, + ) + foss.unlink_folder_content(42) + mocked_logger.info.assert_called_once() + + +@responses.activate +def test_unlink_folder_content_error(foss_server: str, foss: Fossology): + responses.add( + responses.PUT, + f"{foss_server}/api/v1/folders/contents/999/unlink", + status=404, + ) + with pytest.raises(FossologyApiError) as excinfo: + foss.unlink_folder_content(999) + assert "Folder content 999 does not exist" in str(excinfo.value)