Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions docs/management-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>` calls.

```python
from foxnose_sdk import BatchUpsertItem

Expand Down Expand Up @@ -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}")
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion src/foxnose_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,4 @@
"APIRef",
]

__version__ = "0.4.0"
__version__ = "0.4.1"
20 changes: 12 additions & 8 deletions src/foxnose_sdk/flux/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
132 changes: 96 additions & 36 deletions src/foxnose_sdk/management/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading