From b205055ca6d98d7f769a8ecb52f9da0b8866b9d2 Mon Sep 17 00:00:00 2001 From: Tariq Alagha Date: Fri, 8 May 2026 21:29:35 -0700 Subject: [PATCH] feat: add list_docs/update_docs/copy_docs methods on NexsetsResource Wraps GET/POST `/data_sets/{id}/docs` so callers can read, replace, and copy the rich documentation (DocContainer markdown bodies) attached to nexsets. POST has replace-all semantics for the nexset's docs. Notable choices caught during testing: - DocContainerInput sets `extra="ignore"` so a full DocContainer response can be round-tripped through it; the SDK BaseModel default is `extra="allow"` which would otherwise leak server-owned fields back to the API in copy_docs. - DocContainerInput excludes `public` and `tags`. The server rejects `public` ("Input cannot include public attribute"); `tags` is on the response but unconfirmed as writable, so kept out conservatively. Can be added later if/when confirmed. Also expands the previously stub DocContainer response model to its real shape (id, owner, org, doc_type, public, repo_type, repo_config, text, access_roles, tags, copied_from_id, created_at, updated_at). Tests: 6 new unit tests in test_nexsets.py covering list/update/copy including dict-input pass-through (for MCP callers) and copy_docs's empty-source no-op + field-stripping behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-site/docs/guides/coverage.mdx | 2 +- nexla_sdk/models/__init__.py | 3 +- nexla_sdk/models/doc_containers/__init__.py | 2 + nexla_sdk/models/doc_containers/requests.py | 24 +++ nexla_sdk/models/doc_containers/responses.py | 21 +- nexla_sdk/resources/nexsets.py | 119 ++++++++++- tests/unit/test_nexsets.py | 203 +++++++++++++++++++ tests/utils/mock_builders.py | 26 +++ 8 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 nexla_sdk/models/doc_containers/requests.py diff --git a/docs-site/docs/guides/coverage.mdx b/docs-site/docs/guides/coverage.mdx index 98d242e..a48c9cb 100644 --- a/docs-site/docs/guides/coverage.mdx +++ b/docs-site/docs/guides/coverage.mdx @@ -13,7 +13,7 @@ This page maps major OpenAPI areas to SDK resources and methods. All requests us - Destinations (Data Sinks): `client.destinations` - CRUD/activate/pause/copy - Nexsets (Data Sets): `client.nexsets` - - CRUD/activate/pause/samples/copy/docs_recommendation + - CRUD/activate/pause/samples/copy/docs_recommendation/list_docs/update_docs/copy_docs - Credentials: `client.credentials` - CRUD/probe/probe_tree/probe_sample (async/request_id) - Data Maps (Lookups): `client.lookups` diff --git a/nexla_sdk/models/__init__.py b/nexla_sdk/models/__init__.py index ebbffe7..af19444 100644 --- a/nexla_sdk/models/__init__.py +++ b/nexla_sdk/models/__init__.py @@ -56,7 +56,7 @@ DestinationType, DestinationUpdate, ) -from nexla_sdk.models.doc_containers import DocContainer +from nexla_sdk.models.doc_containers import DocContainer, DocContainerInput from nexla_sdk.models.enums import ( AccessRole, ConnectorCategory, @@ -377,6 +377,7 @@ "BlockedDomain", # Doc containers / Data schemas "DocContainer", + "DocContainerInput", "DataSchema", # Webhooks "WebhookSendOptions", diff --git a/nexla_sdk/models/doc_containers/__init__.py b/nexla_sdk/models/doc_containers/__init__.py index a0f9f0f..0743210 100644 --- a/nexla_sdk/models/doc_containers/__init__.py +++ b/nexla_sdk/models/doc_containers/__init__.py @@ -1,5 +1,7 @@ +from .requests import DocContainerInput from .responses import DocContainer __all__ = [ "DocContainer", + "DocContainerInput", ] diff --git a/nexla_sdk/models/doc_containers/requests.py b/nexla_sdk/models/doc_containers/requests.py new file mode 100644 index 0000000..af2f3bc --- /dev/null +++ b/nexla_sdk/models/doc_containers/requests.py @@ -0,0 +1,24 @@ +"""Request models for doc containers.""" + +from typing import Optional + +from pydantic import ConfigDict + +from nexla_sdk.models.base import BaseModel + + +class DocContainerInput(BaseModel): + """Writable fields for creating or replacing a documentation entry. + + Server-owned and read-only fields (``id``, ``owner``, ``org``, + ``public``, ``tags``, ``created_at`` etc.) are silently ignored on + construction so a full ``DocContainer`` response can be round-tripped + through this model to drop fields the API does not accept on input. + """ + + model_config = ConfigDict(extra="ignore") + + name: str + description: Optional[str] = None + doc_type: str = "md" + text: str diff --git a/nexla_sdk/models/doc_containers/responses.py b/nexla_sdk/models/doc_containers/responses.py index 6b78a8d..98e4633 100644 --- a/nexla_sdk/models/doc_containers/responses.py +++ b/nexla_sdk/models/doc_containers/responses.py @@ -1,8 +1,27 @@ -from typing import Optional +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import Field from nexla_sdk.models.base import BaseModel +from nexla_sdk.models.common import Organization, Owner class DocContainer(BaseModel): + """Documentation container attached to a Nexla resource (e.g. a nexset).""" + id: int + owner: Optional[Owner] = None + org: Optional[Organization] = None name: Optional[str] = None + description: Optional[str] = None + doc_type: Optional[str] = None + public: Optional[bool] = None + repo_type: Optional[str] = None + repo_config: Optional[Dict[str, Any]] = None + text: Optional[str] = None + access_roles: List[str] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list) + copied_from_id: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/nexla_sdk/resources/nexsets.py b/nexla_sdk/resources/nexsets.py index 5fcfd31..735fef0 100644 --- a/nexla_sdk/resources/nexsets.py +++ b/nexla_sdk/resources/nexsets.py @@ -1,5 +1,6 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union +from nexla_sdk.models.doc_containers import DocContainer, DocContainerInput from nexla_sdk.models.nexsets.requests import ( NexsetCopyOptions, NexsetCreate, @@ -162,3 +163,119 @@ def docs_recommendation(self, set_id: int) -> Dict[str, Any]: """Generate AI suggestion for Nexset documentation.""" path = f"{self._path}/{set_id}/docs/recommendation" return self._make_request("POST", path) + + def list_docs(self, set_id: int, expand: bool = True) -> List[DocContainer]: + """List documentation entries attached to a nexset. + + Each entry is a ``DocContainer`` carrying the rich documentation body + in its ``text`` field (typically markdown), along with metadata such + as owner, org, doc type, and timestamps. + + Args: + set_id: Nexset ID. + expand: When ``True`` (default), pass ``expand=1`` to the API to + include nested ``owner`` and ``org`` details in each entry. + + Returns: + List of ``DocContainer`` instances. Empty list if the nexset has + no documentation. + + Examples: + Read the markdown body of the first doc on a nexset:: + + docs = client.nexsets.list_docs(419706) + if docs: + print(docs[0].text) + """ + path = f"{self._path}/{set_id}/docs" + params = {"expand": 1} if expand else {} + response = self._make_request("GET", path, params=params) + if isinstance(response, list): + return [DocContainer.model_validate(item) for item in response] + return [] + + def update_docs( + self, + set_id: int, + docs: List[Union[DocContainerInput, Dict[str, Any]]], + ) -> List[DocContainer]: + """Replace all documentation entries on a nexset. + + This call uses **replace-all** semantics: the provided list becomes + the entire set of docs for the nexset. Any existing entries that are + not in the list are removed. + + Args: + set_id: Nexset ID. + docs: New documentation entries. Each item may be a + ``DocContainerInput`` instance or a plain dict with the same + shape (``name``, ``text``, ``doc_type``, optional + ``description``). Plain dicts are accepted to support + generic pass-through callers (e.g. the MCP server, raw + scripts). + + Returns: + The new list of ``DocContainer`` entries as parsed from the + server response. + + Examples: + Replace a nexset's docs with a single markdown entry:: + + from nexla_sdk.models import DocContainerInput + + client.nexsets.update_docs( + 419706, + [DocContainerInput( + name="Overview", + description="High-level description", + text="# Overview\\n\\n...", + )], + ) + """ + path = f"{self._path}/{set_id}/docs" + serialized = [self._serialize_data(d) for d in docs] + response = self._make_request( + "POST", path, json={"docs": serialized} + ) + if isinstance(response, list): + return [DocContainer.model_validate(item) for item in response] + return [] + + def copy_docs(self, src_id: int, dst_id: int) -> List[DocContainer]: + """Copy all documentation entries from one nexset to another. + + Reads the source's docs, strips server-owned fields (id, owner, org, + access_roles, copied_from_id, created_at, updated_at), and writes + them to the destination using ``update_docs`` (replace-all + semantics). Existing docs on the destination are overwritten. + + If the source has no docs, this is a no-op: the destination is left + unchanged and an empty list is returned. + + Note: only fields present on ``DocContainerInput`` are carried over. + Any new server-side doc fields will be ignored until + ``DocContainerInput`` is extended. + + Args: + src_id: Source nexset ID (docs are read from here). + dst_id: Destination nexset ID (docs are written here). + + Returns: + The destination's new list of ``DocContainer`` entries, or an + empty list if the source had no docs. + + Examples: + Copy docs from one nexset to another:: + + client.nexsets.copy_docs(src_id=419706, dst_id=419800) + """ + source_docs = self.list_docs(src_id) + if not source_docs: + return [] + payload = [ + DocContainerInput.model_validate( + doc.model_dump(exclude_none=True) + ) + for doc in source_docs + ] + return self.update_docs(dst_id, payload) diff --git a/tests/unit/test_nexsets.py b/tests/unit/test_nexsets.py index 98909c4..8d6cb06 100644 --- a/tests/unit/test_nexsets.py +++ b/tests/unit/test_nexsets.py @@ -5,6 +5,7 @@ from nexla_sdk.exceptions import NotFoundError, ServerError from nexla_sdk.http_client import HttpClientError +from nexla_sdk.models.doc_containers import DocContainer, DocContainerInput from nexla_sdk.models.nexsets.requests import ( NexsetCopyOptions, NexsetCreate, @@ -338,3 +339,205 @@ def test_empty_list_response(self, mock_client): # Assert assert nexsets == [] assert len(nexsets) == 0 + + def test_list_docs(self, mock_client): + """Test listing docs for a nexset.""" + # Arrange + nexset_id = 419706 + mock_factory = MockDataFactory() + mock_response = [ + mock_factory.create_mock_doc_container( + id=25122, name="Doc 1", text="# Heading\n\nbody" + ) + ] + mock_client.http_client.add_response( + f"/data_sets/{nexset_id}/docs", mock_response + ) + + # Act + docs = mock_client.nexsets.list_docs(nexset_id) + + # Assert + assert len(docs) == 1 + assert isinstance(docs[0], DocContainer) + assert docs[0].id == 25122 + assert docs[0].text == "# Heading\n\nbody" + mock_client.http_client.assert_request_made( + "GET", f"/data_sets/{nexset_id}/docs" + ) + request = mock_client.http_client.get_last_request() + assert request["params"].get("expand") == 1 + + def test_list_docs_no_expand(self, mock_client): + """Test listing docs without the expand flag.""" + # Arrange + nexset_id = 419706 + mock_client.http_client.add_response( + f"/data_sets/{nexset_id}/docs", [] + ) + + # Act + docs = mock_client.nexsets.list_docs(nexset_id, expand=False) + + # Assert + assert docs == [] + request = mock_client.http_client.get_last_request() + assert "expand" not in request["params"] + + def test_update_docs(self, mock_client): + """Test replacing docs on a nexset using DocContainerInput.""" + # Arrange + nexset_id = 419706 + mock_factory = MockDataFactory() + mock_response = [ + mock_factory.create_mock_doc_container( + id=25124, name="Doc 1", text="# Heading\n\nbody" + ) + ] + mock_client.http_client.add_response( + f"/data_sets/{nexset_id}/docs", mock_response + ) + + new_doc = DocContainerInput( + name="Doc 1", + description="d", + text="# Heading\n\nbody", + ) + + # Act + result = mock_client.nexsets.update_docs(nexset_id, [new_doc]) + + # Assert + assert len(result) == 1 + assert isinstance(result[0], DocContainer) + assert result[0].id == 25124 + mock_client.http_client.assert_request_made( + "POST", f"/data_sets/{nexset_id}/docs" + ) + request = mock_client.http_client.get_last_request() + assert "docs" in request["json"] + assert len(request["json"]["docs"]) == 1 + sent = request["json"]["docs"][0] + assert sent["name"] == "Doc 1" + assert sent["text"] == "# Heading\n\nbody" + assert sent["doc_type"] == "md" + + def test_update_docs_accepts_dicts(self, mock_client): + """Test update_docs accepts plain dicts (e.g. from MCP layer).""" + # Arrange + nexset_id = 419706 + mock_client.http_client.add_response( + f"/data_sets/{nexset_id}/docs", [] + ) + + # Act — pass a plain dict, not a DocContainerInput + mock_client.nexsets.update_docs( + nexset_id, + [{"name": "Raw", "doc_type": "md", "text": "# x"}], + ) + + # Assert + request = mock_client.http_client.get_last_request() + assert request["json"] == { + "docs": [{"name": "Raw", "doc_type": "md", "text": "# x"}] + } + + def test_copy_docs(self, mock_client): + """Test copy_docs reads source, strips server fields, writes dest.""" + # Arrange + src_id = 419706 + dst_id = 419800 + mock_factory = MockDataFactory() + + # Source docs include all server-owned fields that must be stripped + source_docs = [ + mock_factory.create_mock_doc_container( + id=25122, + name="Doc 1", + description="desc 1", + doc_type="md", + text="# body 1", + public=False, + tags=["a"], + copied_from_id=None, + ) + ] + # Destination response after the POST + dest_docs = [ + mock_factory.create_mock_doc_container( + id=99999, name="Doc 1", text="# body 1", copied_from_id=25122 + ) + ] + + mock_client.http_client.add_response( + f"/data_sets/{src_id}/docs", source_docs + ) + mock_client.http_client.add_response( + f"/data_sets/{dst_id}/docs", dest_docs + ) + + # Act + result = mock_client.nexsets.copy_docs(src_id, dst_id) + + # Assert + assert len(result) == 1 + assert result[0].id == 99999 + assert result[0].copied_from_id == 25122 + + # Verify GET to source + get_requests = mock_client.http_client.get_requests_by_url_pattern( + f"/data_sets/{src_id}/docs" + ) + assert any(r["method"] == "GET" for r in get_requests) + + # Verify POST to destination with stripped body + dest_requests = mock_client.http_client.get_requests_by_url_pattern( + f"/data_sets/{dst_id}/docs" + ) + post_requests = [r for r in dest_requests if r["method"] == "POST"] + assert len(post_requests) == 1 + sent_docs = post_requests[0]["json"]["docs"] + assert len(sent_docs) == 1 + sent = sent_docs[0] + + # Writable fields are carried over + assert sent["name"] == "Doc 1" + assert sent["description"] == "desc 1" + assert sent["doc_type"] == "md" + assert sent["text"] == "# body 1" + + # Server-owned and read-only fields are stripped + for stripped in ( + "id", + "owner", + "org", + "access_roles", + "copied_from_id", + "created_at", + "updated_at", + "repo_type", + "repo_config", + "public", + "tags", + ): + assert stripped not in sent, f"{stripped!r} should be stripped" + + def test_copy_docs_empty_source(self, mock_client): + """Test copy_docs no-ops when source has no docs.""" + # Arrange + src_id = 419706 + dst_id = 419800 + mock_client.http_client.add_response( + f"/data_sets/{src_id}/docs", [] + ) + + # Act + result = mock_client.nexsets.copy_docs(src_id, dst_id) + + # Assert + assert result == [] + # No POST should have been made to the destination + dest_requests = mock_client.http_client.get_requests_by_url_pattern( + f"/data_sets/{dst_id}/docs" + ) + assert all(r["method"] != "POST" for r in dest_requests) diff --git a/tests/utils/mock_builders.py b/tests/utils/mock_builders.py index 6093a24..7e3087d 100644 --- a/tests/utils/mock_builders.py +++ b/tests/utils/mock_builders.py @@ -906,6 +906,32 @@ def create_mock_lookup_entry(self, **kwargs) -> Dict[str, Any]: "metadata": kwargs.get("metadata", {"source": "test"}), } + def create_mock_doc_container(self, **kwargs) -> Dict[str, Any]: + """Create mock doc container data (nexset documentation entry).""" + return { + "id": kwargs.get("id", self.fake.random_int(min=1, max=100000)), + "owner": kwargs.get("owner", self.create_mock_owner()), + "org": kwargs.get("org", self.create_mock_organization()), + "name": kwargs.get( + "name", f"Doc for Nexset {self.fake.random_int(min=1, max=1000)}" + ), + "description": kwargs.get("description", self.fake.sentence()), + "doc_type": kwargs.get("doc_type", "md"), + "public": kwargs.get("public", False), + "repo_type": kwargs.get("repo_type", "embedded"), + "repo_config": kwargs.get("repo_config", {}), + "text": kwargs.get("text", "# Heading\n\nMarkdown body."), + "access_roles": kwargs.get("access_roles", ["owner"]), + "tags": kwargs.get("tags", []), + "copied_from_id": kwargs.get("copied_from_id"), + "created_at": kwargs.get( + "created_at", self.fake.date_time(tzinfo=timezone.utc).isoformat() + ), + "updated_at": kwargs.get( + "updated_at", self.fake.date_time(tzinfo=timezone.utc).isoformat() + ), + } + def create_mock_org_member(self, **kwargs) -> Dict[str, Any]: """Create mock org member data.""" return {