From bdd4fc7ba639993a56e53ce6a5d62769edfb83fb Mon Sep 17 00:00:00 2001 From: Auto Implementer Date: Tue, 17 Mar 2026 13:06:28 +0000 Subject: [PATCH] feat(gooddata-sdk): [AUTO] add knowledge document REST endpoint wrappers Adds CatalogKnowledgeService with create_document, upsert_document, get_document, list_documents, and delete_document methods wrapping the new afm-exec-api knowledge endpoints. Includes SDK model classes (CatalogKnowledgeDocumentMetadata, request/response DTOs) and exports them via gooddata_sdk.__init__ / GoodDataSdk.catalog_knowledge property. Co-Authored-By: Claude Sonnet 4.6 --- .../gooddata-sdk/src/gooddata_sdk/__init__.py | 10 + .../catalog/knowledge/__init__.py | 1 + .../knowledge/declarative_model/__init__.py | 1 + .../declarative_model/knowledge_document.py | 176 +++++++++++++++ .../gooddata_sdk/catalog/knowledge/service.py | 128 +++++++++++ packages/gooddata-sdk/src/gooddata_sdk/sdk.py | 6 + .../tests/catalog/knowledge/__init__.py | 0 .../test_knowledge_document_service.py | 204 ++++++++++++++++++ 8 files changed, 526 insertions(+) create mode 100644 packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/__init__.py create mode 100644 packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/__init__.py create mode 100644 packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/knowledge_document.py create mode 100644 packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/service.py create mode 100644 packages/gooddata-sdk/tests/catalog/knowledge/__init__.py create mode 100644 packages/gooddata-sdk/tests/catalog/knowledge/test_knowledge_document_service.py diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index fba74d7f0..82507a2ed 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -70,6 +70,16 @@ TokenCredentialsFromEnvVar, TokenCredentialsFromFile, ) +from gooddata_sdk.catalog.knowledge.declarative_model.knowledge_document import ( + CatalogCreateKnowledgeDocumentRequest, + CatalogCreateKnowledgeDocumentResponse, + CatalogDeleteKnowledgeDocumentResponse, + CatalogKnowledgeDocumentMetadata, + CatalogListKnowledgeDocumentsResponse, + CatalogUpsertKnowledgeDocumentRequest, + CatalogUpsertKnowledgeDocumentResponse, +) +from gooddata_sdk.catalog.knowledge.service import CatalogKnowledgeService from gooddata_sdk.catalog.export.request import ( ExportCustomLabel, ExportCustomMetric, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/__init__.py new file mode 100644 index 000000000..06549c73b --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/__init__.py @@ -0,0 +1 @@ +# (C) 2024 GoodData Corporation diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/__init__.py new file mode 100644 index 000000000..06549c73b --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/__init__.py @@ -0,0 +1 @@ +# (C) 2024 GoodData Corporation diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/knowledge_document.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/knowledge_document.py new file mode 100644 index 000000000..aaf6d78bb --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/declarative_model/knowledge_document.py @@ -0,0 +1,176 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from typing import Any + +import attrs + +from gooddata_api_client.model.create_knowledge_document_request_dto import CreateKnowledgeDocumentRequestDto +from gooddata_api_client.model.create_knowledge_document_response_dto import CreateKnowledgeDocumentResponseDto +from gooddata_api_client.model.delete_knowledge_document_response_dto import DeleteKnowledgeDocumentResponseDto +from gooddata_api_client.model.knowledge_document_metadata_dto import KnowledgeDocumentMetadataDto +from gooddata_api_client.model.list_knowledge_documents_response_dto import ListKnowledgeDocumentsResponseDto +from gooddata_api_client.model.upsert_knowledge_document_request_dto import UpsertKnowledgeDocumentRequestDto +from gooddata_api_client.model.upsert_knowledge_document_response_dto import UpsertKnowledgeDocumentResponseDto + + +@attrs.define(kw_only=True) +class CatalogKnowledgeDocumentMetadata: + """Metadata for a knowledge document stored in GoodData.""" + + filename: str + num_chunks: int + created_at: str + created_by: str + updated_at: str + updated_by: str + scopes: list[str] = attrs.field(factory=list) + workspace_id: str | None = None + title: str | None = None + is_disabled: bool | None = None + + @classmethod + def from_api(cls, dto: KnowledgeDocumentMetadataDto) -> CatalogKnowledgeDocumentMetadata: + kwargs: dict[str, Any] = {} + if hasattr(dto, "workspace_id") and dto.get("workspace_id") is not None: + kwargs["workspace_id"] = dto["workspace_id"] + if hasattr(dto, "title") and dto.get("title") is not None: + kwargs["title"] = dto["title"] + if hasattr(dto, "is_disabled") and dto.get("is_disabled") is not None: + kwargs["is_disabled"] = dto["is_disabled"] + scopes = dto.get("scopes") or [] + return cls( + filename=dto["filename"], + num_chunks=dto["num_chunks"], + created_at=dto["created_at"], + created_by=dto["created_by"], + updated_at=dto["updated_at"], + updated_by=dto["updated_by"], + scopes=scopes, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class CatalogCreateKnowledgeDocumentRequest: + """Request to create a new knowledge document (fails if already exists).""" + + filename: str + content: str + title: str | None = None + scopes: list[str] = attrs.field(factory=list) + page_boundaries: list[int] = attrs.field(factory=list) + + def as_api_model(self) -> CreateKnowledgeDocumentRequestDto: + kwargs: dict[str, Any] = {} + if self.title is not None: + kwargs["title"] = self.title + if self.scopes: + kwargs["scopes"] = self.scopes + if self.page_boundaries: + kwargs["page_boundaries"] = self.page_boundaries + return CreateKnowledgeDocumentRequestDto( + filename=self.filename, + content=self.content, + _check_type=False, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class CatalogCreateKnowledgeDocumentResponse: + """Response from creating a knowledge document.""" + + filename: str + message: str + num_chunks: int + success: bool + + @classmethod + def from_api(cls, dto: CreateKnowledgeDocumentResponseDto) -> CatalogCreateKnowledgeDocumentResponse: + return cls( + filename=dto["filename"], + message=dto["message"], + num_chunks=dto["num_chunks"], + success=dto["success"], + ) + + +@attrs.define(kw_only=True) +class CatalogUpsertKnowledgeDocumentRequest: + """Request to upsert a knowledge document (create or update).""" + + filename: str + content: str + title: str | None = None + scopes: list[str] = attrs.field(factory=list) + page_boundaries: list[int] = attrs.field(factory=list) + + def as_api_model(self) -> UpsertKnowledgeDocumentRequestDto: + kwargs: dict[str, Any] = {} + if self.title is not None: + kwargs["title"] = self.title + if self.scopes: + kwargs["scopes"] = self.scopes + if self.page_boundaries: + kwargs["page_boundaries"] = self.page_boundaries + return UpsertKnowledgeDocumentRequestDto( + filename=self.filename, + content=self.content, + _check_type=False, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class CatalogUpsertKnowledgeDocumentResponse: + """Response from upserting a knowledge document.""" + + filename: str + message: str + num_chunks: int + success: bool + + @classmethod + def from_api(cls, dto: UpsertKnowledgeDocumentResponseDto) -> CatalogUpsertKnowledgeDocumentResponse: + return cls( + filename=dto["filename"], + message=dto["message"], + num_chunks=dto["num_chunks"], + success=dto["success"], + ) + + +@attrs.define(kw_only=True) +class CatalogListKnowledgeDocumentsResponse: + """Response from listing knowledge documents.""" + + documents: list[CatalogKnowledgeDocumentMetadata] = attrs.field(factory=list) + next_page_token: str | None = None + total_count: int | None = None + + @classmethod + def from_api(cls, dto: ListKnowledgeDocumentsResponseDto) -> CatalogListKnowledgeDocumentsResponse: + raw_docs = dto.get("documents") or [] + documents = [CatalogKnowledgeDocumentMetadata.from_api(d) for d in raw_docs] + kwargs: dict[str, Any] = {} + if dto.get("next_page_token") is not None: + kwargs["next_page_token"] = dto["next_page_token"] + if dto.get("total_count") is not None: + kwargs["total_count"] = dto["total_count"] + return cls(documents=documents, **kwargs) + + +@attrs.define(kw_only=True) +class CatalogDeleteKnowledgeDocumentResponse: + """Response from deleting a knowledge document.""" + + message: str + success: bool + + @classmethod + def from_api(cls, dto: DeleteKnowledgeDocumentResponseDto) -> CatalogDeleteKnowledgeDocumentResponse: + return cls( + message=dto["message"], + success=dto["success"], + ) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/service.py new file mode 100644 index 000000000..9cf0f712d --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/knowledge/service.py @@ -0,0 +1,128 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from gooddata_api_client.exceptions import NotFoundException + +from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase +from gooddata_sdk.catalog.knowledge.declarative_model.knowledge_document import ( + CatalogCreateKnowledgeDocumentRequest, + CatalogCreateKnowledgeDocumentResponse, + CatalogDeleteKnowledgeDocumentResponse, + CatalogKnowledgeDocumentMetadata, + CatalogListKnowledgeDocumentsResponse, + CatalogUpsertKnowledgeDocumentRequest, + CatalogUpsertKnowledgeDocumentResponse, +) +from gooddata_sdk.client import GoodDataApiClient + + +class CatalogKnowledgeService(CatalogServiceBase): + """Service for managing knowledge documents in GoodData workspaces.""" + + def __init__(self, api_client: GoodDataApiClient) -> None: + super().__init__(api_client) + + def create_document( + self, + workspace_id: str, + request: CatalogCreateKnowledgeDocumentRequest, + ) -> CatalogCreateKnowledgeDocumentResponse: + """Create a new knowledge document (strict create, rejects duplicates). + + Args: + workspace_id (str): Workspace identifier. + request (CatalogCreateKnowledgeDocumentRequest): Document creation request. + + Returns: + CatalogCreateKnowledgeDocumentResponse: Response with document metadata. + """ + response = self._actions_api.create_document( + workspace_id, + request.as_api_model(), + _check_return_type=False, + ) + return CatalogCreateKnowledgeDocumentResponse.from_api(response) + + def upsert_document( + self, + workspace_id: str, + request: CatalogUpsertKnowledgeDocumentRequest, + ) -> CatalogUpsertKnowledgeDocumentResponse: + """Upsert a knowledge document (create or update). + + Args: + workspace_id (str): Workspace identifier. + request (CatalogUpsertKnowledgeDocumentRequest): Document upsert request. + + Returns: + CatalogUpsertKnowledgeDocumentResponse: Response with document metadata. + """ + response = self._actions_api.upsert_document( + workspace_id, + request.as_api_model(), + _check_return_type=False, + ) + return CatalogUpsertKnowledgeDocumentResponse.from_api(response) + + def get_document( + self, + workspace_id: str, + filename: str, + ) -> CatalogKnowledgeDocumentMetadata: + """Get metadata for a single knowledge document. + + Args: + workspace_id (str): Workspace identifier. + filename (str): Document filename. + + Returns: + CatalogKnowledgeDocumentMetadata: Document metadata. + + Raises: + NotFoundException: If the document does not exist. + """ + response = self._actions_api.get_document( + workspace_id, + filename, + _check_return_type=False, + ) + return CatalogKnowledgeDocumentMetadata.from_api(response) + + def list_documents( + self, + workspace_id: str, + ) -> CatalogListKnowledgeDocumentsResponse: + """List all knowledge documents in a workspace. + + Args: + workspace_id (str): Workspace identifier. + + Returns: + CatalogListKnowledgeDocumentsResponse: Response containing list of document metadata. + """ + response = self._actions_api.list_documents( + workspace_id, + _check_return_type=False, + ) + return CatalogListKnowledgeDocumentsResponse.from_api(response) + + def delete_document( + self, + workspace_id: str, + filename: str, + ) -> CatalogDeleteKnowledgeDocumentResponse: + """Delete a knowledge document. + + Args: + workspace_id (str): Workspace identifier. + filename (str): Document filename. + + Returns: + CatalogDeleteKnowledgeDocumentResponse: Response confirming deletion. + """ + response = self._actions_api.delete_document( + workspace_id, + filename, + _check_return_type=False, + ) + return CatalogDeleteKnowledgeDocumentResponse.from_api(response) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/sdk.py b/packages/gooddata-sdk/src/gooddata_sdk/sdk.py index 003840083..60ad9b104 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/sdk.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/sdk.py @@ -6,6 +6,7 @@ from gooddata_sdk.catalog.appearance.service import CatalogAppearanceService from gooddata_sdk.catalog.data_source.service import CatalogDataSourceService from gooddata_sdk.catalog.export.service import ExportService +from gooddata_sdk.catalog.knowledge.service import CatalogKnowledgeService from gooddata_sdk.catalog.organization.service import CatalogOrganizationService from gooddata_sdk.catalog.permission.service import CatalogPermissionService from gooddata_sdk.catalog.user.service import CatalogUserService @@ -89,6 +90,7 @@ def __init__(self, client: GoodDataApiClient) -> None: self._support = SupportService(self._client) self._catalog_permission = CatalogPermissionService(self._client) self._export = ExportService(self._client) + self._catalog_knowledge = CatalogKnowledgeService(self._client) @property def catalog_appearance(self) -> CatalogAppearanceService: @@ -138,6 +140,10 @@ def catalog_permission(self) -> CatalogPermissionService: def export(self) -> ExportService: return self._export + @property + def catalog_knowledge(self) -> CatalogKnowledgeService: + return self._catalog_knowledge + @property def client(self) -> GoodDataApiClient: return self._client diff --git a/packages/gooddata-sdk/tests/catalog/knowledge/__init__.py b/packages/gooddata-sdk/tests/catalog/knowledge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/gooddata-sdk/tests/catalog/knowledge/test_knowledge_document_service.py b/packages/gooddata-sdk/tests/catalog/knowledge/test_knowledge_document_service.py new file mode 100644 index 000000000..1934cf3b4 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/knowledge/test_knowledge_document_service.py @@ -0,0 +1,204 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from gooddata_sdk.catalog.knowledge.declarative_model.knowledge_document import ( + CatalogCreateKnowledgeDocumentRequest, + CatalogCreateKnowledgeDocumentResponse, + CatalogDeleteKnowledgeDocumentResponse, + CatalogKnowledgeDocumentMetadata, + CatalogListKnowledgeDocumentsResponse, + CatalogUpsertKnowledgeDocumentRequest, + CatalogUpsertKnowledgeDocumentResponse, +) +from gooddata_sdk.catalog.knowledge.service import CatalogKnowledgeService + + +def _make_metadata_dto( + filename: str = "doc.txt", + num_chunks: int = 3, + created_at: str = "2024-01-01T00:00:00Z", + created_by: str = "user1", + updated_at: str = "2024-01-02T00:00:00Z", + updated_by: str = "user2", + scopes: list[str] | None = None, + workspace_id: str | None = None, + title: str | None = None, +) -> MagicMock: + dto = MagicMock() + dto.__getitem__ = lambda self, key: { + "filename": filename, + "num_chunks": num_chunks, + "created_at": created_at, + "created_by": created_by, + "updated_at": updated_at, + "updated_by": updated_by, + "scopes": scopes or [], + "workspace_id": workspace_id, + "title": title, + "is_disabled": None, + }[key] + dto.get = lambda key, default=None: { + "filename": filename, + "num_chunks": num_chunks, + "created_at": created_at, + "created_by": created_by, + "updated_at": updated_at, + "updated_by": updated_by, + "scopes": scopes or [], + "workspace_id": workspace_id, + "title": title, + "is_disabled": None, + }.get(key, default) + return dto + + +def _make_write_response_dto( + filename: str = "doc.txt", + message: str = "OK", + num_chunks: int = 3, + success: bool = True, +) -> MagicMock: + dto = MagicMock() + dto.__getitem__ = lambda self, key: { + "filename": filename, + "message": message, + "num_chunks": num_chunks, + "success": success, + }[key] + return dto + + +def _make_delete_response_dto(message: str = "Deleted", success: bool = True) -> MagicMock: + dto = MagicMock() + dto.__getitem__ = lambda self, key: {"message": message, "success": success}[key] + return dto + + +def _make_list_response_dto(documents: list[MagicMock] | None = None) -> MagicMock: + dto = MagicMock() + dto.get = lambda key, default=None: { + "documents": documents or [], + "next_page_token": None, + "total_count": None, + }.get(key, default) + return dto + + +def _make_service() -> tuple[CatalogKnowledgeService, MagicMock]: + api_client = MagicMock() + service = CatalogKnowledgeService.__new__(CatalogKnowledgeService) + service._actions_api = MagicMock() + return service, service._actions_api + + +class TestCatalogKnowledgeDocumentMetadata: + def test_from_api_minimal(self) -> None: + dto = _make_metadata_dto() + metadata = CatalogKnowledgeDocumentMetadata.from_api(dto) + assert metadata.filename == "doc.txt" + assert metadata.num_chunks == 3 + assert metadata.created_at == "2024-01-01T00:00:00Z" + assert metadata.created_by == "user1" + assert metadata.updated_at == "2024-01-02T00:00:00Z" + assert metadata.updated_by == "user2" + assert metadata.scopes == [] + assert metadata.workspace_id is None + assert metadata.title is None + + def test_from_api_with_optional_fields(self) -> None: + dto = _make_metadata_dto( + scopes=["scope1", "scope2"], + workspace_id="ws1", + title="My Document", + ) + metadata = CatalogKnowledgeDocumentMetadata.from_api(dto) + assert metadata.scopes == ["scope1", "scope2"] + assert metadata.workspace_id == "ws1" + assert metadata.title == "My Document" + + +class TestCatalogCreateKnowledgeDocumentRequest: + def test_as_api_model_required_only(self) -> None: + request = CatalogCreateKnowledgeDocumentRequest(filename="doc.txt", content="Hello world") + model = request.as_api_model() + assert model["filename"] == "doc.txt" + assert model["content"] == "Hello world" + + def test_as_api_model_with_optional_fields(self) -> None: + request = CatalogCreateKnowledgeDocumentRequest( + filename="doc.txt", + content="Hello world", + title="My Doc", + scopes=["scope1"], + page_boundaries=[100, 200], + ) + model = request.as_api_model() + assert model["filename"] == "doc.txt" + assert model["title"] == "My Doc" + assert model["scopes"] == ["scope1"] + assert model["page_boundaries"] == [100, 200] + + +class TestCatalogUpsertKnowledgeDocumentRequest: + def test_as_api_model_required_only(self) -> None: + request = CatalogUpsertKnowledgeDocumentRequest(filename="doc.txt", content="Hello world") + model = request.as_api_model() + assert model["filename"] == "doc.txt" + assert model["content"] == "Hello world" + + +class TestCatalogKnowledgeService: + def test_create_document_calls_api(self) -> None: + service, actions_api = _make_service() + actions_api.create_document.return_value = _make_write_response_dto() + request = CatalogCreateKnowledgeDocumentRequest(filename="doc.txt", content="content") + response = service.create_document("ws1", request) + actions_api.create_document.assert_called_once() + assert isinstance(response, CatalogCreateKnowledgeDocumentResponse) + assert response.filename == "doc.txt" + assert response.success is True + + def test_upsert_document_calls_api(self) -> None: + service, actions_api = _make_service() + actions_api.upsert_document.return_value = _make_write_response_dto() + request = CatalogUpsertKnowledgeDocumentRequest(filename="doc.txt", content="content") + response = service.upsert_document("ws1", request) + actions_api.upsert_document.assert_called_once() + assert isinstance(response, CatalogUpsertKnowledgeDocumentResponse) + assert response.success is True + + def test_get_document_calls_api(self) -> None: + service, actions_api = _make_service() + actions_api.get_document.return_value = _make_metadata_dto(title="My Doc") + result = service.get_document("ws1", "doc.txt") + actions_api.get_document.assert_called_once_with("ws1", "doc.txt", _check_return_type=False) + assert isinstance(result, CatalogKnowledgeDocumentMetadata) + assert result.filename == "doc.txt" + + def test_list_documents_calls_api(self) -> None: + service, actions_api = _make_service() + doc_dto = _make_metadata_dto(filename="file1.txt") + actions_api.list_documents.return_value = _make_list_response_dto(documents=[doc_dto]) + result = service.list_documents("ws1") + actions_api.list_documents.assert_called_once_with("ws1", _check_return_type=False) + assert isinstance(result, CatalogListKnowledgeDocumentsResponse) + assert len(result.documents) == 1 + assert result.documents[0].filename == "file1.txt" + + def test_list_documents_empty(self) -> None: + service, actions_api = _make_service() + actions_api.list_documents.return_value = _make_list_response_dto(documents=[]) + result = service.list_documents("ws1") + assert result.documents == [] + + def test_delete_document_calls_api(self) -> None: + service, actions_api = _make_service() + actions_api.delete_document.return_value = _make_delete_response_dto() + result = service.delete_document("ws1", "doc.txt") + actions_api.delete_document.assert_called_once_with("ws1", "doc.txt", _check_return_type=False) + assert isinstance(result, CatalogDeleteKnowledgeDocumentResponse) + assert result.success is True