diff --git a/README.md b/README.md index 49f0160..ffa1d45 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Python versions](https://img.shields.io/pypi/pyversions/foxnose-sdk.svg)](https://pypi.org/project/foxnose-sdk/) [![CI](https://github.com/FoxNoseTech/foxnose-python/actions/workflows/ci.yml/badge.svg)](https://github.com/FoxNoseTech/foxnose-python/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/FoxNoseTech/foxnose-python/graph/badge.svg)](https://codecov.io/gh/FoxNoseTech/foxnose-python) +[![Docs](https://img.shields.io/badge/docs-foxnose--python.readthedocs.io-blue)](https://foxnose-python.readthedocs.io/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [FoxNose](https://foxnose.net?utm_source=github&utm_medium=repository&utm_campaign=foxnose-python) is a managed knowledge layer for RAG and AI agents — auto-embeddings, hybrid search, and zero ETL pipelines to maintain. diff --git a/docs/changelog.md b/docs/changelog.md index dcb1efb..fbeb46c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-03-05 + +### Fixed + +- **Flux role permission objects handling** in Management clients: + - normalize permission object list responses consistently + - keep compatibility with paginated/object payload variants + - align role-scoped flux permission object behavior with production contract + ## [0.4.0] - 2026-02-25 ### Added @@ -79,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error handling guide - Code examples +[0.4.1]: https://github.com/foxnose/python-sdk/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/foxnose/python-sdk/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/foxnose/python-sdk/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/foxnose/python-sdk/compare/v0.1.0...v0.2.0 diff --git a/docs/management-client.md b/docs/management-client.md index 245cef3..930382b 100644 --- a/docs/management-client.md +++ b/docs/management-client.md @@ -246,6 +246,8 @@ resource = client.upsert_resource( Upsert many resources in parallel. The SDK fans out individual `upsert_resource()` calls using threads (sync client) or async tasks (async client), controlled by `max_concurrency`. +This is an SDK helper, not a separate Management API endpoint. Under the hood it executes concurrent `PUT /v1/:env/folders/:folder/resources/?external_id=` calls. + ```python from foxnose_sdk import BatchUpsertItem @@ -274,10 +276,10 @@ result = await client.batch_upsert_resources("folder-key", items, max_concurrenc - `fail_fast=True` — stop on the first error and raise it immediately. ```python -# Raises FoxnoseAPIError on first failure +# Raises on first failed upsert call try: result = client.batch_upsert_resources("folder-key", items, fail_fast=True) -except FoxnoseAPIError as exc: +except Exception as exc: print(f"Batch stopped: {exc}") ``` @@ -471,9 +473,23 @@ client.upsert_flux_role_permission( { "content_type": "flux-apis", "actions": ["read"], - "all_objects": True, + "all_objects": False, + }, +) + +# Scope access to specific Flux APIs +client.add_flux_permission_object( + "role-key", + { + "content_type": "flux-apis", + "object_key": "api-key-1", }, ) + +objects = client.list_flux_permission_objects( + "role-key", content_type="flux-apis" +) +# objects is a list[RolePermissionObject] ``` ### API Keys diff --git a/pyproject.toml b/pyproject.toml index ff9f313..861bb3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "foxnose-sdk" -version = "0.4.0" +version = "0.4.1" description = "Official Python client for FoxNose Management and Flux APIs" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/src/foxnose_sdk/__init__.py b/src/foxnose_sdk/__init__.py index c03a76d..f653d46 100644 --- a/src/foxnose_sdk/__init__.py +++ b/src/foxnose_sdk/__init__.py @@ -154,4 +154,4 @@ "APIRef", ] -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/src/foxnose_sdk/flux/client.py b/src/foxnose_sdk/flux/client.py index 09631ba..932c43e 100644 --- a/src/foxnose_sdk/flux/client.py +++ b/src/foxnose_sdk/flux/client.py @@ -83,15 +83,17 @@ def search( path = self._build_path(folder_path, suffix="/_search") return self._transport.request("POST", path, json_body=body) - def get_router(self) -> Any: + def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any: """Return available routes and contracts under the configured API prefix.""" path = f"/{self.api_prefix}/_router" - return self._transport.request("GET", path) + return self._transport.request("GET", path, params=params) - def get_schema(self, folder_path: str) -> Any: + def get_schema( + self, folder_path: str, *, params: Mapping[str, Any] | None = None + ) -> Any: """Return live JSON Schema and metadata for the given folder path.""" path = self._build_path(folder_path, suffix="/_schema") - return self._transport.request("GET", path) + return self._transport.request("GET", path, params=params) def close(self) -> None: self._transport.close() @@ -163,15 +165,17 @@ async def search( path = self._build_path(folder_path, suffix="/_search") return await self._transport.arequest("POST", path, json_body=body) - async def get_router(self) -> Any: + async def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any: """Return available routes and contracts under the configured API prefix.""" path = f"/{self.api_prefix}/_router" - return await self._transport.arequest("GET", path) + return await self._transport.arequest("GET", path, params=params) - async def get_schema(self, folder_path: str) -> Any: + async def get_schema( + self, folder_path: str, *, params: Mapping[str, Any] | None = None + ) -> Any: """Return live JSON Schema and metadata for the given folder path.""" path = self._build_path(folder_path, suffix="/_schema") - return await self._transport.arequest("GET", path) + return await self._transport.arequest("GET", path, params=params) async def aclose(self) -> None: await self._transport.aclose() diff --git a/src/foxnose_sdk/management/client.py b/src/foxnose_sdk/management/client.py index a5b53a0..584f170 100644 --- a/src/foxnose_sdk/management/client.py +++ b/src/foxnose_sdk/management/client.py @@ -70,6 +70,47 @@ def _resolve_key(value: str | BaseModel) -> str: return key +def _coerce_list_payload(payload: Any) -> list[Any]: + """Normalize list-like API payloads. + + Supports both legacy list responses and paginated envelopes that expose + list items under ``results``. + """ + if payload is None: + return [] + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + results = payload.get("results") + if isinstance(results, list): + return results + return [payload] + return [payload] + + +def _coerce_permission_object_payload( + payload: Any, request_payload: Mapping[str, Any] +) -> dict[str, Any]: + """Normalize permission-object payloads for create operations. + + Some API endpoints return `201 Created` with an empty response body. + In that case we reconstruct the object from the original request payload. + """ + if isinstance(payload, Mapping): + data = dict(payload) + if "object_key" not in data and "object" in data: + data["object_key"] = data.pop("object") + return data + + object_key = request_payload.get("object_key") + if object_key is None: + object_key = request_payload.get("object") + return { + "content_type": request_payload.get("content_type"), + "object_key": object_key, + } + + FolderRef = Union[str, FolderSummary] ResourceRef = Union[str, ResourceSummary] RevisionRef = Union[str, RevisionSummary] @@ -719,8 +760,11 @@ def list_management_role_permissions( role_key: Unique identifier of the role. """ role_key = _resolve_key(role_key) - payload = self.request("GET", f"{self._role_permissions_root(role_key)}/") or [] - return [RolePermission.model_validate(item) for item in payload] + payload = self.request("GET", f"{self._role_permissions_root(role_key)}/") + return [ + RolePermission.model_validate(item) + for item in _coerce_list_payload(payload) + ] def upsert_management_role_permission( self, @@ -790,13 +834,13 @@ def list_management_permission_objects( """ role_key = _resolve_key(role_key) params = {"content_type": content_type} - payload = ( - self.request( - "GET", f"{self._role_permission_objects_root(role_key)}/", params=params - ) - or [] + payload = self.request( + "GET", f"{self._role_permission_objects_root(role_key)}/", params=params ) - return [RolePermissionObject.model_validate(item) for item in payload] + return [ + RolePermissionObject.model_validate(item) + for item in _coerce_list_payload(payload) + ] def add_management_permission_object( self, @@ -815,7 +859,9 @@ def add_management_permission_object( f"{self._role_permission_objects_root(role_key)}/", json_body=payload, ) - return RolePermissionObject.model_validate(data) + return RolePermissionObject.model_validate( + _coerce_permission_object_payload(data, payload) + ) def delete_management_permission_object( self, @@ -897,10 +943,11 @@ def list_flux_role_permissions(self, role_key: FluxRoleRef) -> list[RolePermissi role_key: Unique identifier of the role. """ role_key = _resolve_key(role_key) - payload = ( - self.request("GET", f"{self._flux_role_permissions_root(role_key)}/") or [] - ) - return [RolePermission.model_validate(item) for item in payload] + payload = self.request("GET", f"{self._flux_role_permissions_root(role_key)}/") + return [ + RolePermission.model_validate(item) + for item in _coerce_list_payload(payload) + ] def upsert_flux_role_permission( self, role_key: FluxRoleRef, payload: Mapping[str, Any] @@ -966,15 +1013,15 @@ def list_flux_permission_objects( content_type: Content type to filter by. """ role_key = _resolve_key(role_key) - payload = ( - self.request( - "GET", - f"{self._flux_role_permission_objects_root(role_key)}/", - params={"content_type": content_type}, - ) - or [] + payload = self.request( + "GET", + f"{self._flux_role_permission_objects_root(role_key)}/", + params={"content_type": content_type}, ) - return [RolePermissionObject.model_validate(item) for item in payload] + return [ + RolePermissionObject.model_validate(item) + for item in _coerce_list_payload(payload) + ] def add_flux_permission_object( self, role_key: FluxRoleRef, payload: Mapping[str, Any] @@ -991,7 +1038,9 @@ def add_flux_permission_object( f"{self._flux_role_permission_objects_root(role_key)}/", json_body=payload, ) - return RolePermissionObject.model_validate(data) + return RolePermissionObject.model_validate( + _coerce_permission_object_payload(data, payload) + ) def delete_flux_permission_object( self, role_key: FluxRoleRef, payload: Mapping[str, Any] @@ -2664,10 +2713,11 @@ async def list_management_role_permissions( self, role_key: ManagementRoleRef ) -> list[RolePermission]: role_key = _resolve_key(role_key) - payload = ( - await self.request("GET", f"{self._role_permissions_root(role_key)}/") or [] - ) - return [RolePermission.model_validate(item) for item in payload] + payload = await self.request("GET", f"{self._role_permissions_root(role_key)}/") + return [ + RolePermission.model_validate(item) + for item in _coerce_list_payload(payload) + ] async def upsert_management_role_permission( self, @@ -2716,8 +2766,10 @@ async def list_management_permission_objects( f"{self._role_permission_objects_root(role_key)}/", params={"content_type": content_type}, ) - payload = payload or [] - return [RolePermissionObject.model_validate(item) for item in payload] + return [ + RolePermissionObject.model_validate(item) + for item in _coerce_list_payload(payload) + ] async def add_management_permission_object( self, @@ -2730,7 +2782,9 @@ async def add_management_permission_object( f"{self._role_permission_objects_root(role_key)}/", json_body=payload, ) - return RolePermissionObject.model_validate(data) + return RolePermissionObject.model_validate( + _coerce_permission_object_payload(data, payload) + ) async def delete_management_permission_object( self, @@ -2781,11 +2835,13 @@ async def list_flux_role_permissions( self, role_key: FluxRoleRef ) -> list[RolePermission]: role_key = _resolve_key(role_key) - payload = ( - await self.request("GET", f"{self._flux_role_permissions_root(role_key)}/") - or [] + payload = await self.request( + "GET", f"{self._flux_role_permissions_root(role_key)}/" ) - return [RolePermission.model_validate(item) for item in payload] + return [ + RolePermission.model_validate(item) + for item in _coerce_list_payload(payload) + ] async def upsert_flux_role_permission( self, role_key: FluxRoleRef, payload: Mapping[str, Any] @@ -2832,8 +2888,10 @@ async def list_flux_permission_objects( f"{self._flux_role_permission_objects_root(role_key)}/", params={"content_type": content_type}, ) - payload = payload or [] - return [RolePermissionObject.model_validate(item) for item in payload] + return [ + RolePermissionObject.model_validate(item) + for item in _coerce_list_payload(payload) + ] async def add_flux_permission_object( self, @@ -2846,7 +2904,9 @@ async def add_flux_permission_object( f"{self._flux_role_permission_objects_root(role_key)}/", json_body=payload, ) - return RolePermissionObject.model_validate(data) + return RolePermissionObject.model_validate( + _coerce_permission_object_payload(data, payload) + ) async def delete_flux_permission_object( self, diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index a6f63d1..3ca3290 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -984,14 +984,22 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path.endswith( "/permissions/objects/" ): - return httpx.Response(200, json=[PERMISSION_OBJECT_JSON]) + return httpx.Response( + 200, + json={ + "count": 1, + "next": None, + "previous": None, + "results": [PERMISSION_OBJECT_JSON], + }, + ) if request.method == "GET" and request.url.path.endswith("/permissions/"): return httpx.Response(200, json=[ROLE_PERMISSION_JSON]) if request.method == "POST" and request.url.path.endswith( "/permissions/objects/" ): body = json.loads(request.content.decode()) - return httpx.Response(201, json=PERMISSION_OBJECT_JSON | body) + return httpx.Response(201) if request.method == "POST" and request.url.path.endswith( "/permissions/batch/" ): @@ -1028,6 +1036,7 @@ def handler(request: httpx.Request) -> httpx.Response: added = await client.add_management_permission_object( "role-1", PERMISSION_OBJECT_JSON ) + assert added.content_type == "folder-items" assert added.object_key == "folder-1" await client.delete_management_permission_object("role-1", PERMISSION_OBJECT_JSON) @@ -1036,21 +1045,29 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_flux_role_permissions_workflow(): - recorded: list[tuple[str, str]] = [] + recorded: list[tuple[str, str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: - recorded.append((request.method, request.url.path)) + recorded.append((request.method, request.url.path, str(request.url))) if request.method == "GET" and request.url.path.endswith( "/permissions/objects/" ): - return httpx.Response(200, json=[FLUX_PERMISSION_OBJECT_JSON]) + return httpx.Response( + 200, + json={ + "count": 1, + "next": None, + "previous": None, + "results": [FLUX_PERMISSION_OBJECT_JSON], + }, + ) if request.method == "GET" and request.url.path.endswith("/permissions/"): return httpx.Response(200, json=[FLUX_ROLE_PERMISSION_JSON]) if request.method == "POST" and request.url.path.endswith( "/permissions/objects/" ): body = json.loads(request.content.decode()) - return httpx.Response(201, json=FLUX_PERMISSION_OBJECT_JSON | body) + return httpx.Response(201) if request.method == "POST" and request.url.path.endswith( "/permissions/batch/" ): @@ -1083,10 +1100,17 @@ def handler(request: httpx.Request) -> httpx.Response: "flux-role-1", content_type="flux-apis" ) assert objects[0].object_key == "api-1" + assert any( + method == "GET" + and "/permissions/flux-api/roles/flux-role-1/permissions/objects/" in url + and "content_type=flux-apis" in url + for method, _, url in recorded + ) added = await client.add_flux_permission_object( "flux-role-1", FLUX_PERMISSION_OBJECT_JSON ) + assert added.content_type == "flux-apis" assert added.object_key == "api-1" await client.delete_flux_permission_object( diff --git a/tests/test_clients.py b/tests/test_clients.py index a585e83..aba293b 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -11,7 +11,12 @@ from foxnose_sdk.config import FoxnoseConfig from foxnose_sdk.flux.client import FluxClient from foxnose_sdk.http import HttpTransport -from foxnose_sdk.management.client import ManagementClient, _resolve_key +from foxnose_sdk.management.client import ( + ManagementClient, + _coerce_list_payload, + _coerce_permission_object_payload, + _resolve_key, +) from foxnose_sdk.errors import FoxnoseAPIError from foxnose_sdk.management.models import ( BatchItemError, @@ -698,7 +703,15 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path.endswith( "/permissions/objects/" ): - return httpx.Response(200, json=[PERMISSION_OBJECT_JSON]) + return httpx.Response( + 200, + json={ + "count": 1, + "next": None, + "previous": None, + "results": [PERMISSION_OBJECT_JSON], + }, + ) if request.method == "GET" and request.url.path.endswith("/permissions/"): return httpx.Response(200, json=[ROLE_PERMISSION_JSON]) if request.method == "POST" and request.url.path.endswith( @@ -706,7 +719,7 @@ def handler(request: httpx.Request) -> httpx.Response: ): body = json.loads(request.content.decode()) bodies.append(body) - return httpx.Response(201, json=PERMISSION_OBJECT_JSON | body) + return httpx.Response(201) if request.method == "POST" and request.url.path.endswith( "/permissions/batch/" ): @@ -746,6 +759,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert objects[0].object_key == "folder-1" added = client.add_management_permission_object("role-1", PERMISSION_OBJECT_JSON) + assert added.content_type == "folder-items" assert added.object_key == "folder-1" client.delete_management_permission_object("role-1", PERMISSION_OBJECT_JSON) @@ -777,7 +791,7 @@ def handler(request: httpx.Request) -> httpx.Response: ): body = json.loads(request.content.decode()) bodies.append(body) - return httpx.Response(201, json=FLUX_PERMISSION_OBJECT_JSON | body) + return httpx.Response(201) if request.method == "POST" and request.url.path.endswith( "/permissions/batch/" ): @@ -793,7 +807,15 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path.endswith( "/permissions/objects/" ): - return httpx.Response(200, json=[FLUX_PERMISSION_OBJECT_JSON]) + return httpx.Response( + 200, + json={ + "count": 1, + "next": None, + "previous": None, + "results": [FLUX_PERMISSION_OBJECT_JSON], + }, + ) if request.method == "GET" and request.url.path.endswith("/permissions/"): return httpx.Response(200, json=[FLUX_ROLE_PERMISSION_JSON]) if request.method == "GET": @@ -842,10 +864,16 @@ def handler(request: httpx.Request) -> httpx.Response: "flux-role-1", content_type="flux-apis" ) assert objects[0].object_key == "api-1" + assert any( + "/permissions/flux-api/roles/flux-role-1/permissions/objects/" in url + and "content_type=flux-apis" in url + for url in captured + ) added = client.add_flux_permission_object( "flux-role-1", FLUX_PERMISSION_OBJECT_JSON ) + assert added.content_type == "flux-apis" assert added.object_key == "api-1" client.delete_flux_permission_object("flux-role-1", FLUX_PERMISSION_OBJECT_JSON) @@ -1609,6 +1637,36 @@ class BadKey: _resolve_key(BadKey()) # type: ignore[arg-type] +# --------------------------------------------------------------------------- +# payload coercion helpers +# --------------------------------------------------------------------------- + + +def test_coerce_list_payload_handles_none_dict_and_scalar(): + assert _coerce_list_payload(None) == [] + assert _coerce_list_payload({"content_type": "flux-apis"}) == [ + {"content_type": "flux-apis"} + ] + assert _coerce_list_payload("single-item") == ["single-item"] + + +def test_coerce_permission_object_payload_handles_mapping_and_object_alias(): + payload = _coerce_permission_object_payload( + {"content_type": "flux-apis", "object": "api-1"}, + {"content_type": "flux-apis", "object_key": "ignored"}, + ) + assert payload["content_type"] == "flux-apis" + assert payload["object_key"] == "api-1" + + +def test_coerce_permission_object_payload_falls_back_to_request_object(): + payload = _coerce_permission_object_payload( + None, + {"content_type": "flux-apis", "object": "api-2"}, + ) + assert payload == {"content_type": "flux-apis", "object_key": "api-2"} + + # --------------------------------------------------------------------------- # Model objects as identifiers (sync client) # ---------------------------------------------------------------------------