diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..264c92e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,158 @@ +# Changelog + +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). + +## [Unreleased] + +## [0.6.0] - 2026-07-16 + +### Added + +- **Typed billing exceptions** in `foxnose_sdk.errors`, all subclasses of `FoxnoseAPIError`: + - `SpendCapExceeded` (HTTP 402 `spend_cap_reached`) — attrs `cap_usd`, `cycle_resets_at`, `raise_cap_url` + - `PlanExhausted` (HTTP 402 `plan_exhausted`) — attrs `axis`, `window_resets_at`, `upgrade_url` + - `PlanLimitExceeded` (HTTP 403 `plan_limit_exceeded`) — attrs `entity`, `limit`, `current`, `upgrade_url` + - `RateLimitExceeded` (HTTP 429 `rate_limited`) — attr `retry_after` (from the `Retry-After` header) +- All four are exported from the package root and caught by `except FoxnoseAPIError`. +- **Components on Collections** — `NestedFieldMeta` helper for building nested-field `meta` (`component`, `component_version`, `auto_update`), and `sync_collection_component()` on `ManagementClient` / `AsyncManagementClient` to advance pinned nested fields to a target Component version. New models `SyncComponentResponse`, `SyncComponentSkippedItem`, and `ComponentSyncConflictDetail`. + +### Changed + +- **Renamed Folder → Collection across the Management API surface.** New `*_collection*` methods (`list_collections`, `create_collection`, `list_collection_versions`, `list_collection_fields`, etc.) and models (`CollectionSummary`, `CollectionList`, `APICollectionSummary`, `APICollectionList`) are the preferred names. The old `*_folder*` methods and `FolderSummary` / `FolderList` remain as deprecated aliases that emit a one-shot `DeprecationWarning` on first use and will be removed in 1.0. + +### Removed + +- Composite folder support. + +### Fixed + +- `RolePermission.all_objects` is now optional (`bool | None`), so permissions for non-object-based content types — which the API returns with `all_objects: null` — parse without error. +- Corrected the `roles_and_permissions` example and the docs to the real permission wire-shape (`content_type` + `actions` list, with `all_objects` only on object-based content types), the renamed content-type keys (`collection-structure` / `collection-items` in place of `folder-*`), and the real API-key create shape (`description` + a single `role`). + +## [0.5.0] - 2026-03-19 + +### Added + +- **Vector search models** in `foxnose_sdk.flux.models`: + - `SearchMode` enum (`text`, `vector`, `vector_boosted`, `hybrid`) + - `VectorSearch` — auto-generated embedding search configuration + - `VectorFieldSearch` — custom pre-computed embedding search configuration + - `VectorBoostConfig` — boost configuration for `vector_boosted` mode + - `HybridConfig` — weight configuration for `hybrid` mode + - `SearchRequest` — typed search payload with cross-field validation +- **Convenience methods** on `FluxClient` and `AsyncFluxClient`: + - `vector_search()` — semantic search with auto-generated embeddings + - `vector_field_search()` — search with custom embedding vectors + - `hybrid_search()` — blended text + vector search + - `boosted_search()` — keyword search boosted by vector similarity +- **Vector Search documentation** — dedicated guide covering all search modes +- All convenience methods support `offset` and `**extra_body` pass-through for additional API parameters (`where`, `sort`, etc.) + +### Fixed + +- `examples/flux_client.py` search example now uses correct API keys (`find_text` and `results`) + +## [0.4.2] - 2026-03-10 + +### Fixed + +- **Secure Management/Flux signing with query parameters**: + - `SecureKeyAuth` now signs only the URL path (without query string) + - aligns SDK signatures with server-side verification and Management auth docs + - prevents `401 authentication_failed` / `Invalid signature` on requests with query params + +## [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 + +- **Flux introspection methods** on sync and async clients: + - `get_router()` calls `GET /{api_prefix}/_router` + - `get_schema(folder_path)` calls `GET /{api_prefix}/{folder_path}/_schema` +- **API folder route description support** in Management clients: + - `add_api_folder()` and `update_api_folder()` now accept: + - `description_get_one` + - `description_get_many` + - `description_search` + - `description_schema` +- **`APIFolderSummary` model fields** for route descriptions: + - `description_get_one` + - `description_get_many` + - `description_search` + - `description_schema` + +## [0.3.0] - 2026-02-10 + +### Added + +- **`upsert_resource()`** method on `ManagementClient` and `AsyncManagementClient` — create or update a resource by `external_id` in a single call. Uses `PUT /folders/:folder/resources/?external_id=`. +- **`batch_upsert_resources()`** method on `ManagementClient` and `AsyncManagementClient` — upsert multiple resources concurrently with configurable `max_concurrency`, `fail_fast` error handling mode, and optional `on_progress` callback. +- **`BatchUpsertItem`**, **`BatchItemError`**, **`BatchUpsertResult`** models for batch upsert input/output. +- **`external_id`** optional parameter on `create_resource()` — assign an external identifier when creating a resource via `POST`. +- **`external_id`** field on `ResourceSummary` model — populated in API responses for resources that have an external identifier. + +## [0.2.0] - 2026-01-26 + +### Added + +- **Model objects as identifiers** — Management client methods now accept either string keys or corresponding model objects (e.g. `FolderSummary`, `ResourceSummary`) wherever a `*_key` parameter is used. This eliminates the need to manually extract `.key` from objects returned by the API. +- `_resolve_key()` helper function for extracting string keys from model objects. +- 13 type aliases for method parameters: `FolderRef`, `ResourceRef`, `RevisionRef`, `ComponentRef`, `SchemaVersionRef`, `OrgRef`, `ProjectRef`, `EnvironmentRef`, `ManagementRoleRef`, `FluxRoleRef`, `ManagementAPIKeyRef`, `FluxAPIKeyRef`, `APIRef`. + +## [0.1.0] - 2026-01-14 + +### Added + +- Initial release of the FoxNose Python SDK +- `ManagementClient` for administrative operations +- `AsyncManagementClient` for async administrative operations +- `FluxClient` for content delivery +- `AsyncFluxClient` for async content delivery +- JWT authentication with automatic token refresh +- API key authentication for Flux API +- Comprehensive type hints and Pydantic models +- Automatic retry with exponential backoff +- Full support for all Management API endpoints: + - Organizations + - Projects + - Environments + - Folders + - Resources + - Revisions + - Schema versions and fields + - Components + - Locales + - Management roles and permissions + - Flux roles and permissions + - Management API keys + - Flux API keys + +### Documentation + +- Getting started guide +- Authentication guide +- Management Client reference +- Flux Client reference +- Error handling guide +- Code examples + +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.2...v0.5.0 +[0.4.2]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/FoxNoseTech/foxnose-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index ffa1d45..8a07d7c 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,20 @@ client = ManagementClient( auth=JWTAuth.from_static_token("YOUR_ACCESS_TOKEN"), ) -# List folders -folders = client.list_folders() -for folder in folders.results: - print(f"{folder.name} ({folder.key})") +# List collections +collections = client.list_collections() +for collection in collections.results: + print(f"{collection.name} ({collection.key})") client.close() ``` +> **Note (0.6.0):** Folder-named methods (`list_folders`, `create_folder`, `add_api_folder`, +> `list_folder_versions`, `list_folder_fields`, etc.) remain as deprecated aliases that +> emit a one-shot `DeprecationWarning` on first use per process. They keep their +> original wire behaviour (hitting the legacy `/folders/...` URL alias on the server) +> and will be removed in **1.0**. Prefer the `*_collection*` names in new code. + ### Async Client ```python @@ -69,10 +75,98 @@ async def main(): auth=JWTAuth.from_static_token("YOUR_ACCESS_TOKEN"), ) - folders = await client.list_folders() + collections = await client.list_collections() await client.aclose() ``` +### Components on Collections + +Collections can embed Components as nested fields with explicit pin +semantics (`component`, `component_version`, `auto_update`). The +`NestedFieldMeta` helper builds the `meta` block for you, and +`sync_collection_component` advances pinned fields to a target Component +version on demand. + +```python +from foxnose_sdk import ( + ManagementClient, + FoxnoseConfig, + NestedFieldMeta, +) +from foxnose_sdk.auth import JWTAuth + +client = ManagementClient( + FoxnoseConfig(base_url="https://api.foxnose.com"), + environment_key="prod", + auth=JWTAuth("ACCESS_TOKEN"), +) + +# Embed a Component as a pinned nested field on a Collection draft. +client.create_collection_field( + "articles", + "v2-draft", + { + "key": "seo", + "name": "SEO", + "type": "nested", + "required": True, + "meta": NestedFieldMeta( + component="cmp-seo-metadata", + component_version="ver-abc12345", + auto_update=False, # default — pin until explicit sync + ).to_meta(), + }, +) + +# Later, advance every pinned nested field to its Component's current +# version (empty body = sync all pinned). +result = client.sync_collection_component("articles") +print(result.synced_paths, result.schema_version) + +# Advance specific paths to a chosen Component version. +result = client.sync_collection_component( + "articles", + field_paths=["seo"], + to_versions={"seo": "ver-def67890"}, +) +``` + +`sync_collection_component` returns a `SyncComponentResponse` with +`synced_paths`, `skipped` (per-path reasons), and `schema_version` (UID +of the newly published Collection schema version, or `None` if no field +needed advancing). On compatibility conflict the server returns 409 +`component_sync_conflict`; quota exhaustion returns 422 +`too_many_versions`. Both surface as `FoxnoseAPIError`. + +### Handling billing errors + +Billing and quota responses raise typed subclasses of `FoxnoseAPIError`, so +existing `except FoxnoseAPIError` handlers keep working while new code can read +the typed attributes: + +```python +from foxnose_sdk import ( + SpendCapExceeded, + PlanExhausted, + PlanLimitExceeded, + RateLimitExceeded, +) + +try: + client.create_collection({"name": "Blog"}) +except SpendCapExceeded as e: # HTTP 402 + print(f"Spend cap {e.cap_usd}; resets at {e.cycle_resets_at}: {e.raise_cap_url}") +except PlanExhausted as e: # HTTP 402 + print(f"Allowance for {e.axis} exhausted; resets at {e.window_resets_at}") +except PlanLimitExceeded as e: # HTTP 403 + print(f"{e.entity}: {e.current}/{e.limit}. Upgrade: {e.upgrade_url}") +except RateLimitExceeded as e: # HTTP 429 + print(f"Rate limited; retry after {e.retry_after}s") +``` + +All four subclass `FoxnoseAPIError`, so a single `except FoxnoseAPIError` still +catches them if you don't need the typed fields. + ### Flux Client ```python diff --git a/docs/changelog.md b/docs/changelog.md index f57c9cf..264c92e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-16 + +### Added + +- **Typed billing exceptions** in `foxnose_sdk.errors`, all subclasses of `FoxnoseAPIError`: + - `SpendCapExceeded` (HTTP 402 `spend_cap_reached`) — attrs `cap_usd`, `cycle_resets_at`, `raise_cap_url` + - `PlanExhausted` (HTTP 402 `plan_exhausted`) — attrs `axis`, `window_resets_at`, `upgrade_url` + - `PlanLimitExceeded` (HTTP 403 `plan_limit_exceeded`) — attrs `entity`, `limit`, `current`, `upgrade_url` + - `RateLimitExceeded` (HTTP 429 `rate_limited`) — attr `retry_after` (from the `Retry-After` header) +- All four are exported from the package root and caught by `except FoxnoseAPIError`. +- **Components on Collections** — `NestedFieldMeta` helper for building nested-field `meta` (`component`, `component_version`, `auto_update`), and `sync_collection_component()` on `ManagementClient` / `AsyncManagementClient` to advance pinned nested fields to a target Component version. New models `SyncComponentResponse`, `SyncComponentSkippedItem`, and `ComponentSyncConflictDetail`. + +### Changed + +- **Renamed Folder → Collection across the Management API surface.** New `*_collection*` methods (`list_collections`, `create_collection`, `list_collection_versions`, `list_collection_fields`, etc.) and models (`CollectionSummary`, `CollectionList`, `APICollectionSummary`, `APICollectionList`) are the preferred names. The old `*_folder*` methods and `FolderSummary` / `FolderList` remain as deprecated aliases that emit a one-shot `DeprecationWarning` on first use and will be removed in 1.0. + +### Removed + +- Composite folder support. + +### Fixed + +- `RolePermission.all_objects` is now optional (`bool | None`), so permissions for non-object-based content types — which the API returns with `all_objects: null` — parse without error. +- Corrected the `roles_and_permissions` example and the docs to the real permission wire-shape (`content_type` + `actions` list, with `all_objects` only on object-based content types), the renamed content-type keys (`collection-structure` / `collection-items` in place of `folder-*`), and the real API-key create shape (`description` + a single `role`). + ## [0.5.0] - 2026-03-19 ### Added @@ -122,7 +147,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error handling guide - Code examples -[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.2...v0.5.0 [0.4.2]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.4.0...v0.4.1 diff --git a/docs/error-handling.md b/docs/error-handling.md index 48a5d45..8d44838 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -16,7 +16,7 @@ try: except FoxnoseAPIError as e: print(f"Status: {e.status_code}") print(f"Message: {e.message}") - print(f"Details: {e.details}") + print(f"Details: {e.detail}") ``` #### Attributes @@ -25,7 +25,43 @@ except FoxnoseAPIError as e: |-----------|------|-------------| | `status_code` | `int` | HTTP status code | | `message` | `str` | Error message from the API | -| `details` | `dict \| None` | Additional error details (if provided) | +| `error_code` | `str \| None` | Machine-readable error code (if provided) | +| `detail` | `dict \| None` | Additional error details (if provided) | + +### Billing errors + +Billing and quota responses raise typed subclasses of `FoxnoseAPIError`. They +are caught by `except FoxnoseAPIError`, and each exposes typed attributes: + +| Exception | Status / code | Attributes | +|-----------|---------------|------------| +| `SpendCapExceeded` | 402 `spend_cap_reached` | `cap_usd`, `cycle_resets_at`, `raise_cap_url` | +| `PlanExhausted` | 402 `plan_exhausted` | `axis`, `window_resets_at`, `upgrade_url` | +| `PlanLimitExceeded` | 403 `plan_limit_exceeded` | `entity`, `limit`, `current`, `upgrade_url` | +| `RateLimitExceeded` | 429 `rate_limited` | `retry_after` | + +```python +from foxnose_sdk import ( + SpendCapExceeded, + PlanExhausted, + PlanLimitExceeded, + RateLimitExceeded, +) + +try: + client.create_collection({"name": "Blog"}) +except SpendCapExceeded as e: + print(f"Spend cap {e.cap_usd}; resets at {e.cycle_resets_at}") +except PlanExhausted as e: + print(f"Allowance for {e.axis} exhausted; resets at {e.window_resets_at}") +except PlanLimitExceeded as e: + print(f"{e.entity}: {e.current}/{e.limit}. Upgrade: {e.upgrade_url}") +except RateLimitExceeded as e: + print(f"Rate limited; retry after {e.retry_after}s") +``` + +`upgrade_url` on `PlanLimitExceeded` may be `None` for entities that have a hard +ceiling with no higher tier. ## Common Error Codes @@ -107,24 +143,25 @@ try: ) except FoxnoseAPIError as e: if e.status_code == 422: - print("Validation failed:", e.details) + print("Validation failed:", e.detail) ``` ### 429 Too Many Requests -Rate limit exceeded: +Rate limit exceeded. Raised as `RateLimitExceeded`, which exposes the parsed +`Retry-After` header as `retry_after` (seconds): ```python import time +from foxnose_sdk import RateLimitExceeded try: for i in range(1000): - client.list_folders() -except FoxnoseAPIError as e: - if e.status_code == 429: - retry_after = e.details.get("retry_after", 60) - print(f"Rate limited. Retry after {retry_after} seconds") - time.sleep(retry_after) + client.list_collections() +except RateLimitExceeded as e: + retry_after = e.retry_after or 60 + print(f"Rate limited. Retry after {retry_after} seconds") + time.sleep(retry_after) ``` ### 500+ Server Errors @@ -231,8 +268,8 @@ try: }) except FoxnoseAPIError as e: if e.status_code in (400, 422): - if e.details and "errors" in e.details: - for field, messages in e.details["errors"].items(): + if e.detail and "errors" in e.detail: + for field, messages in e.detail["errors"].items(): print(f" {field}: {', '.join(messages)}") ``` diff --git a/docs/examples.md b/docs/examples.md index 116beb3..209488d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -222,7 +222,6 @@ role = client.create_management_role({ client.upsert_management_role_permission(role.key, { "content_type": "resources", "actions": ["read", "create", "update"], - "all_objects": True, }) # Create an API key with this role diff --git a/docs/management-client.md b/docs/management-client.md index 930382b..f8a22cd 100644 --- a/docs/management-client.md +++ b/docs/management-client.md @@ -453,7 +453,6 @@ client.upsert_management_role_permission( { "content_type": "resources", "actions": ["read", "create", "update"], - "all_objects": True, }, ) ``` diff --git a/examples/README.md b/examples/README.md index 06c959b..7f36df0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -65,17 +65,17 @@ auth = APIKeyAuth("YOUR_API_KEY") ## Error Handling -All API errors raise `FoxNoseAPIError`: +All API errors raise `FoxnoseAPIError`: ```python -from foxnose_sdk.errors import FoxNoseAPIError +from foxnose_sdk.errors import FoxnoseAPIError try: resource = client.get_resource(folder_key, resource_key) -except FoxNoseAPIError as e: +except FoxnoseAPIError as e: print(f"Status: {e.status_code}") print(f"Message: {e.message}") - print(f"Details: {e.details}") + print(f"Details: {e.detail}") ``` ## Need Help? diff --git a/examples/folder_schema.py b/examples/folder_schema.py index 62d156d..0a439dd 100644 --- a/examples/folder_schema.py +++ b/examples/folder_schema.py @@ -97,8 +97,8 @@ def main(): except FoxnoseAPIError as e: print(f"API Error: {e.message}") - if e.details: - print(f"Details: {e.details}") + if e.detail: + print(f"Details: {e.detail}") finally: client.close() diff --git a/examples/resources_and_revisions.py b/examples/resources_and_revisions.py index e1bf4f2..4abaf83 100644 --- a/examples/resources_and_revisions.py +++ b/examples/resources_and_revisions.py @@ -81,8 +81,8 @@ def main(): except FoxnoseAPIError as e: print(f"API Error: {e.message}") - if e.details: - print(f"Details: {e.details}") + if e.detail: + print(f"Details: {e.detail}") finally: client.close() diff --git a/examples/roles_and_permissions.py b/examples/roles_and_permissions.py index 16a3817..6918199 100644 --- a/examples/roles_and_permissions.py +++ b/examples/roles_and_permissions.py @@ -25,42 +25,36 @@ def main(): # Management API Roles # ====================== - # Create a Management API role for content editors + # Create a Management API role with read-only access mgmt_role = client.create_management_role( { - "name": "Content Editor", - "description": "Can manage content but not settings", + "name": "Collection Reader", + "description": "Read-only access to collection structure and items", "full_access": False, } ) print(f"Created Management role: {mgmt_role.key}") - # Add permissions to the role - # Allow read/write access to documents + # Grant read-only access to the collection structure client.upsert_management_role_permission( mgmt_role.key, { - "content_type": "document", - "can_read": True, - "can_create": True, - "can_update": True, - "can_delete": False, # Cannot delete + "content_type": "collection-structure", + "actions": ["read"], }, ) - print(" Added document permissions") + print(" Added collection-structure permission (read-only)") - # Allow read-only access to folders + # Grant read-only access to items across all collections client.upsert_management_role_permission( mgmt_role.key, { - "content_type": "folder", - "can_read": True, - "can_create": False, - "can_update": False, - "can_delete": False, + "content_type": "collection-items", + "actions": ["read"], + "all_objects": True, }, ) - print(" Added folder permissions (read-only)") + print(" Added collection-items permission (read-only, all collections)") # List all permissions for the role permissions = client.list_management_role_permissions(mgmt_role.key) @@ -79,18 +73,17 @@ def main(): ) print(f"\nCreated Flux role: {flux_role.key}") - # Add permissions - Flux roles typically have read-only access + # Add permissions - Flux roles have read-only access. + # all_objects=True grants read access to every Flux API in the environment. client.upsert_flux_role_permission( flux_role.key, { - "content_type": "document", - "can_read": True, - "can_create": False, - "can_update": False, - "can_delete": False, + "content_type": "flux-apis", + "actions": ["read"], + "all_objects": True, }, ) - print(" Added document read permission") + print(" Added flux-apis read permission (all APIs)") # ====================== # API Keys @@ -99,21 +92,21 @@ def main(): # Create a Management API key with the role mgmt_key = client.create_management_api_key( { - "name": "Editor API Key", - "roles": [mgmt_role.key], + "description": "Reader API key", + "role": mgmt_role.key, } ) - print(f"\nCreated Management API key: {mgmt_key.key}") - # Note: The actual secret is only shown once upon creation + print(f"\nCreated Management API key: {mgmt_key.public_key}") + # Note: the secret key (mgmt_key.secret_key) is only returned once here # Create a Flux API key for frontend flux_key = client.create_flux_api_key( { - "name": "Frontend API Key", - "roles": [flux_role.key], + "description": "Frontend API key", + "role": flux_role.key, } ) - print(f"Created Flux API key: {flux_key.key}") + print(f"Created Flux API key: {flux_key.public_key}") # ====================== # Cleanup @@ -129,8 +122,8 @@ def main(): except FoxnoseAPIError as e: print(f"API Error: {e.message}") - if e.details: - print(f"Details: {e.details}") + if e.detail: + print(f"Details: {e.detail}") finally: client.close() diff --git a/pyproject.toml b/pyproject.toml index 76254b0..e194032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "foxnose-sdk" -version = "0.5.0" +version = "0.6.0" 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 2891621..fda0c84 100644 --- a/src/foxnose_sdk/__init__.py +++ b/src/foxnose_sdk/__init__.py @@ -18,6 +18,10 @@ FoxnoseAuthError, FoxnoseError, FoxnoseTransportError, + PlanExhausted, + PlanLimitExceeded, + RateLimitExceeded, + SpendCapExceeded, ) from .flux.client import AsyncFluxClient, FluxClient from .flux.models import ( @@ -31,6 +35,7 @@ from .management.client import ( APIRef, AsyncManagementClient, + CollectionRef, ComponentRef, EnvironmentRef, FluxAPIKeyRef, @@ -46,11 +51,16 @@ SchemaVersionRef, ) from .management.models import ( + APICollectionList, + APICollectionSummary, BatchItemError, BatchUpsertItem, BatchUpsertResult, + CollectionList, + CollectionSummary, ComponentList, ComponentSummary, + ComponentSyncConflictDetail, EnvironmentList, EnvironmentSummary, FieldList, @@ -67,6 +77,7 @@ FluxRoleSummary, LocaleList, LocaleSummary, + NestedFieldMeta, OrganizationList, OrganizationOwner, OrganizationPlanStatus, @@ -86,6 +97,8 @@ RevisionSummary, SchemaVersionList, SchemaVersionSummary, + SyncComponentResponse, + SyncComponentSkippedItem, ) __all__ = [ @@ -103,6 +116,10 @@ "FoxnoseAPIError", "FoxnoseAuthError", "FoxnoseTransportError", + "SpendCapExceeded", + "PlanExhausted", + "PlanLimitExceeded", + "RateLimitExceeded", "ManagementClient", "AsyncManagementClient", "FluxClient", @@ -116,8 +133,16 @@ "BatchUpsertResult", "FolderSummary", "FolderList", + "CollectionSummary", + "CollectionList", + "APICollectionSummary", + "APICollectionList", "ComponentSummary", "ComponentList", + "ComponentSyncConflictDetail", + "NestedFieldMeta", + "SyncComponentResponse", + "SyncComponentSkippedItem", "SchemaVersionSummary", "SchemaVersionList", "FieldSummary", @@ -148,6 +173,7 @@ "RolePermissionObject", "UserReference", "FolderRef", + "CollectionRef", "ResourceRef", "RevisionRef", "ComponentRef", @@ -168,4 +194,4 @@ "SearchRequest", ] -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/foxnose_sdk/_deprecation.py b/src/foxnose_sdk/_deprecation.py new file mode 100644 index 0000000..8556253 --- /dev/null +++ b/src/foxnose_sdk/_deprecation.py @@ -0,0 +1,31 @@ +"""One-shot DeprecationWarning helpers for renamed SDK methods. + +Each (old_name) is warned at most once per process. Caller-side filtering +with ``warnings.filterwarnings("error", category=DeprecationWarning)`` is +respected because we emit a standard library ``DeprecationWarning``. +""" + +import warnings + +_warned: set[str] = set() + + +def warn_deprecated_method( + old_name: str, new_name: str, *, removal: str = "1.0" +) -> None: + """Emit a :class:`DeprecationWarning` at most once per process per ``old_name``. + + Args: + old_name: The method name being deprecated (e.g. ``"list_folders"``). + new_name: The replacement method name (e.g. ``"list_collections"``). + removal: The SDK version where the old name will be removed. + """ + if old_name in _warned: + return + _warned.add(old_name) + warnings.warn( + f"foxnose-sdk: {old_name}() is deprecated; use {new_name}() instead. " + f"{old_name}() will be removed in foxnose-sdk {removal}.", + DeprecationWarning, + stacklevel=3, + ) diff --git a/src/foxnose_sdk/errors.py b/src/foxnose_sdk/errors.py index 276249a..7bfd26d 100644 --- a/src/foxnose_sdk/errors.py +++ b/src/foxnose_sdk/errors.py @@ -24,9 +24,154 @@ def __str__(self) -> str: # pragma: no cover - trivial return f"{self.message} (status={self.status_code}{code})" +class SpendCapExceeded(FoxnoseAPIError): + """Raised on HTTP 402 ``spend_cap_reached`` — the account spend cap was hit.""" + + def __init__( + self, + *, + cap_usd: float | None = None, + cycle_resets_at: str | None = None, + raise_cap_url: str | None = None, + **base_kwargs: Any, + ) -> None: + super().__init__(**base_kwargs) + self.cap_usd = cap_usd + self.cycle_resets_at = cycle_resets_at + self.raise_cap_url = raise_cap_url + + +class PlanExhausted(FoxnoseAPIError): + """Raised on HTTP 402 ``plan_exhausted`` — a metered plan allowance ran out.""" + + def __init__( + self, + *, + axis: str | None = None, + window_resets_at: str | None = None, + upgrade_url: str | None = None, + **base_kwargs: Any, + ) -> None: + super().__init__(**base_kwargs) + self.axis = axis + self.window_resets_at = window_resets_at + self.upgrade_url = upgrade_url + + +class PlanLimitExceeded(FoxnoseAPIError): + """Raised on HTTP 403 ``plan_limit_exceeded`` — a plan entity ceiling was hit.""" + + def __init__( + self, + *, + entity: str | None = None, + limit: int | None = None, + current: int | None = None, + upgrade_url: str | None = None, + **base_kwargs: Any, + ) -> None: + super().__init__(**base_kwargs) + self.entity = entity + self.limit = limit + self.current = current + self.upgrade_url = upgrade_url + + +class RateLimitExceeded(FoxnoseAPIError): + """Raised on HTTP 429 ``rate_limited`` — too many requests.""" + + def __init__( + self, + *, + retry_after: float | None = None, + **base_kwargs: Any, + ) -> None: + super().__init__(**base_kwargs) + self.retry_after = retry_after + + class FoxnoseAuthError(FoxnoseError): """Raised when authentication headers cannot be generated.""" class FoxnoseTransportError(FoxnoseError): """Raised when the HTTP layer fails before receiving a response.""" + + +def _header_lookup(headers: Mapping[str, str] | None, name: str) -> str | None: + """Case-insensitively read a header value (httpx lowercases header names).""" + if not headers: + return None + target = name.lower() + for key, value in headers.items(): + if key.lower() == target: + return value + return None + + +def build_api_error( + *, + message: str, + status_code: int, + error_code: str | None, + detail: Any | None, + response_headers: Mapping[str, str] | None, + response_body: Any | None, +) -> FoxnoseAPIError: + """Build the most specific ``FoxnoseAPIError`` subclass for a response. + + Mapping is exact on ``(status_code, error_code)``; anything else — including + a malformed body on a mapped status — falls through to the base class. This + never raises while parsing. + """ + base_kwargs: dict[str, Any] = { + "message": message, + "status_code": status_code, + "error_code": error_code, + "detail": detail, + "response_headers": response_headers, + "response_body": response_body, + } + body = response_body if isinstance(response_body, dict) else None + + if status_code == 402 and error_code == "spend_cap_reached" and body is not None: + if "message" not in body: + base_kwargs["message"] = "Spend cap reached" + return SpendCapExceeded( + cap_usd=body.get("cap_usd"), + cycle_resets_at=body.get("cycle_resets_at"), + raise_cap_url=body.get("raise_cap_url"), + **base_kwargs, + ) + + if status_code == 402 and error_code == "plan_exhausted" and body is not None: + if "message" not in body: + base_kwargs["message"] = "Plan allowance exhausted" + return PlanExhausted( + axis=body.get("axis"), + window_resets_at=body.get("window_resets_at"), + upgrade_url=body.get("upgrade_url"), + **base_kwargs, + ) + + if status_code == 403 and error_code == "plan_limit_exceeded": + detail_obj = detail if isinstance(detail, dict) else {} + return PlanLimitExceeded( + entity=detail_obj.get("entity"), + limit=detail_obj.get("limit"), + current=detail_obj.get("current"), + upgrade_url=detail_obj.get("upgrade_url"), + **base_kwargs, + ) + + if status_code == 429 and error_code == "rate_limited": + retry_after: float | None = None + raw_retry_after = _header_lookup(response_headers, "Retry-After") + if raw_retry_after is not None: + try: + retry_after = float(raw_retry_after) + except ValueError: + retry_after = None + return RateLimitExceeded(retry_after=retry_after, **base_kwargs) + + return FoxnoseAPIError(**base_kwargs) diff --git a/src/foxnose_sdk/http.py b/src/foxnose_sdk/http.py index 2b5ec7f..f7c01d6 100644 --- a/src/foxnose_sdk/http.py +++ b/src/foxnose_sdk/http.py @@ -9,7 +9,7 @@ from .auth.base import AnonymousAuth, AuthStrategy, RequestData from .config import FoxnoseConfig, RetryConfig -from .errors import FoxnoseAPIError, FoxnoseTransportError +from .errors import FoxnoseTransportError, build_api_error JSONDecoder = Callable[[httpx.Response], Any] @@ -243,13 +243,14 @@ def _raise_api_error(response: httpx.Response) -> None: if response.content: try: payload = response.json() - message = payload.get("message", message) - error_code = payload.get("error_code") - detail = payload.get("detail") body = payload + if isinstance(payload, dict): + message = payload.get("message", message) + error_code = payload.get("error_code") + detail = payload.get("detail") except json.JSONDecodeError: body = response.text - raise FoxnoseAPIError( + raise build_api_error( message=message or "API request failed", status_code=response.status_code, error_code=error_code, diff --git a/src/foxnose_sdk/management/__init__.py b/src/foxnose_sdk/management/__init__.py index 179a7f9..e991869 100644 --- a/src/foxnose_sdk/management/__init__.py +++ b/src/foxnose_sdk/management/__init__.py @@ -3,6 +3,7 @@ from .client import ( APIRef, AsyncManagementClient, + CollectionRef, ComponentRef, EnvironmentRef, FluxAPIKeyRef, @@ -18,13 +19,21 @@ SchemaVersionRef, ) from .models import ( + APICollectionList, + APICollectionSummary, BatchItemError, BatchUpsertItem, BatchUpsertResult, + CollectionList, + CollectionSummary, + ComponentSyncConflictDetail, + NestedFieldMeta, ResourceList, ResourceSummary, RevisionList, RevisionSummary, + SyncComponentResponse, + SyncComponentSkippedItem, ) __all__ = [ @@ -38,6 +47,11 @@ "BatchItemError", "BatchUpsertResult", "FolderRef", + "CollectionRef", + "CollectionSummary", + "CollectionList", + "APICollectionSummary", + "APICollectionList", "ResourceRef", "RevisionRef", "ComponentRef", @@ -50,4 +64,8 @@ "ManagementAPIKeyRef", "FluxAPIKeyRef", "APIRef", + "NestedFieldMeta", + "SyncComponentResponse", + "SyncComponentSkippedItem", + "ComponentSyncConflictDetail", ] diff --git a/src/foxnose_sdk/management/client.py b/src/foxnose_sdk/management/client.py index 584f170..71168ac 100644 --- a/src/foxnose_sdk/management/client.py +++ b/src/foxnose_sdk/management/client.py @@ -7,10 +7,13 @@ from pydantic import BaseModel +from .._deprecation import warn_deprecated_method from ..auth import AuthStrategy from ..config import FoxnoseConfig, RetryConfig from ..http import HttpTransport from .models import ( + APICollectionList, + APICollectionSummary, APIFolderList, APIFolderSummary, APIInfo, @@ -28,6 +31,8 @@ FluxAPIKeySummary, FluxRoleList, FluxRoleSummary, + CollectionList, + CollectionSummary, FolderList, FolderSummary, LocaleList, @@ -51,6 +56,7 @@ RolePermissionObject, SchemaVersionList, SchemaVersionSummary, + SyncComponentResponse, ) @@ -112,6 +118,8 @@ def _coerce_permission_object_payload( FolderRef = Union[str, FolderSummary] +# Collection-named alias — same union, preferred name in new code. +CollectionRef = FolderRef ResourceRef = Union[str, ResourceSummary] RevisionRef = Union[str, RevisionSummary] ComponentRef = Union[str, ComponentSummary] @@ -149,7 +157,7 @@ def _environment_root( ) -> str: return f"{self._environments_base(org_key, project_key)}/{environment_key}" - # Folder paths + # Folder paths (deprecated — see Collection paths below). def _folders_root(self) -> str: return f"/v1/{self.environment_key}/folders" @@ -168,6 +176,32 @@ def _folder_versions_base(self, folder_key: str) -> str: def _folder_schema_tree(self, folder_key: str, version_key: str) -> str: return f"{self._folder_versions_base(folder_key)}/{version_key}/schema/tree" + # Collection paths (canonical going forward; folder paths above hit the + # deprecated /folders/ URL alias on the server side). + def _collections_root(self) -> str: + return f"/v1/{self.environment_key}/collections" + + def _collections_tree_root(self) -> str: + return f"{self._collections_root()}/tree" + + def _collections_tree_item(self) -> str: + return f"{self._collections_tree_root()}/collection" + + def _collection_root(self, collection_key: str) -> str: + return f"{self._collections_root()}/{collection_key}" + + def _collection_versions_base(self, collection_key: str) -> str: + return f"{self._collection_root(collection_key)}/model/versions" + + def _collection_schema_tree(self, collection_key: str, version_key: str) -> str: + return f"{self._collection_versions_base(collection_key)}/{version_key}/schema/tree" + + def _collection_sync_component(self, collection_key: str) -> str: + return f"{self._collection_root(collection_key)}/sync_component" + + def _api_collections_root(self, api_key: str) -> str: + return f"{self._api_root(api_key)}/collections" + # Component paths def _components_root(self) -> str: return f"/v1/{self.environment_key}/components" @@ -414,7 +448,7 @@ def create_management_api_key( """Create a new Management API key. Args: - payload: Key configuration including name and role assignments. + payload: Key configuration: a ``description`` and an optional single ``role``. """ data = self.request( "POST", f"{self._management_api_keys_root()}/", json_body=payload @@ -474,7 +508,7 @@ def create_flux_api_key(self, payload: Mapping[str, Any]) -> FluxAPIKeySummary: """Create a new Flux API key. Args: - payload: Key configuration including name and role assignments. + payload: Key configuration: a ``description`` and an optional single ``role``. """ data = self.request("POST", f"{self._flux_api_keys_root()}/", json_body=payload) return FluxAPIKeySummary.model_validate(data) @@ -565,42 +599,150 @@ def delete_api(self, api_key: APIRef) -> None: api_key = _resolve_key(api_key) self.request("DELETE", f"{self._api_root(api_key)}/", parse_json=False) - def list_api_folders( + # ------------------------------------------------------------------ # + # API ↔ Collection association (canonical) + # ------------------------------------------------------------------ # + + def list_api_collections( self, api_key: APIRef, *, params: Mapping[str, Any] | None = None - ) -> APIFolderList: - """List folders exposed through an API. + ) -> APICollectionList: + """List collections exposed through an API. Args: api_key: Unique identifier of the API. params: Optional query parameters for filtering/pagination. """ api_key = _resolve_key(api_key) - data = self.request("GET", f"{self._api_folders_root(api_key)}/", params=params) - return APIFolderList.model_validate(data) + data = self.request( + "GET", f"{self._api_collections_root(api_key)}/", params=params + ) + return APICollectionList.model_validate(data) - def add_api_folder( + def add_api_collection( self, api_key: APIRef, - folder_key: FolderRef, + collection_key: CollectionRef, *, allowed_methods: list[str] | None = None, description_get_one: str | None = None, description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, - ) -> APIFolderSummary: - """Add a folder to an API. + ) -> APICollectionSummary: + """Add a collection to an API. Args: api_key: Unique identifier of the API. - folder_key: Unique identifier of the folder to add. - allowed_methods: HTTP methods allowed for this folder (e.g., ["GET", "POST"]). + collection_key: Unique identifier of the collection to add. + allowed_methods: HTTP methods allowed (e.g., ``["GET", "POST"]``). description_get_one: Optional short description for the get-one route. description_get_many: Optional short description for the list route. description_search: Optional short description for the search route. description_schema: Optional short description for the schema route. + + Note: + The POST body uses the wire field name ``folder`` for compatibility. """ api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + payload: dict[str, Any] = {"folder": collection_key} + if allowed_methods is not None: + payload["allowed_methods"] = allowed_methods + if description_get_one is not None: + payload["description_get_one"] = description_get_one + if description_get_many is not None: + payload["description_get_many"] = description_get_many + if description_search is not None: + payload["description_search"] = description_search + if description_schema is not None: + payload["description_schema"] = description_schema + data = self.request( + "POST", f"{self._api_collections_root(api_key)}/", json_body=payload + ) + return APICollectionSummary.model_validate(data) + + def get_api_collection( + self, api_key: APIRef, collection_key: CollectionRef + ) -> APICollectionSummary: + """Retrieve details for a collection within an API.""" + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + data = self.request( + "GET", f"{self._api_collections_root(api_key)}/{collection_key}/" + ) + return APICollectionSummary.model_validate(data) + + def update_api_collection( + self, + api_key: APIRef, + collection_key: CollectionRef, + *, + allowed_methods: list[str] | None = None, + description_get_one: str | None = None, + description_get_many: str | None = None, + description_search: str | None = None, + description_schema: str | None = None, + ) -> APICollectionSummary: + """Update a collection's configuration within an API.""" + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + payload: dict[str, Any] = {} + if allowed_methods is not None: + payload["allowed_methods"] = allowed_methods + if description_get_one is not None: + payload["description_get_one"] = description_get_one + if description_get_many is not None: + payload["description_get_many"] = description_get_many + if description_search is not None: + payload["description_search"] = description_search + if description_schema is not None: + payload["description_schema"] = description_schema + data = self.request( + "PUT", + f"{self._api_collections_root(api_key)}/{collection_key}/", + json_body=payload, + ) + return APICollectionSummary.model_validate(data) + + def remove_api_collection( + self, api_key: APIRef, collection_key: CollectionRef + ) -> None: + """Remove a collection from an API.""" + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + self.request( + "DELETE", + f"{self._api_collections_root(api_key)}/{collection_key}/", + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # API ↔ Folder association (deprecated; hit legacy /folders/ alias) + # ------------------------------------------------------------------ # + + def list_api_folders( + self, api_key: APIRef, *, params: Mapping[str, Any] | None = None + ) -> APIFolderList: + """Deprecated alias for :meth:`list_api_collections`.""" + warn_deprecated_method("list_api_folders", "list_api_collections") + api_key = _resolve_key(api_key) + data = self.request("GET", f"{self._api_folders_root(api_key)}/", params=params) + return APIFolderList.model_validate(data) + + def add_api_folder( + self, + api_key: APIRef, + folder_key: FolderRef, + *, + allowed_methods: list[str] | None = None, + description_get_one: str | None = None, + description_get_many: str | None = None, + description_search: str | None = None, + description_schema: str | None = None, + ) -> APIFolderSummary: + """Deprecated alias for :meth:`add_api_collection`.""" + warn_deprecated_method("add_api_folder", "add_api_collection") + api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) payload: dict[str, Any] = {"folder": folder_key} if allowed_methods is not None: @@ -621,12 +763,8 @@ def add_api_folder( def get_api_folder( self, api_key: APIRef, folder_key: FolderRef ) -> APIFolderSummary: - """Retrieve details for a folder within an API. - - Args: - api_key: Unique identifier of the API. - folder_key: Unique identifier of the folder. - """ + """Deprecated alias for :meth:`get_api_collection`.""" + warn_deprecated_method("get_api_folder", "get_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) data = self.request("GET", f"{self._api_folders_root(api_key)}/{folder_key}/") @@ -643,17 +781,8 @@ def update_api_folder( description_search: str | None = None, description_schema: str | None = None, ) -> APIFolderSummary: - """Update a folder's configuration within an API. - - Args: - api_key: Unique identifier of the API. - folder_key: Unique identifier of the folder. - allowed_methods: HTTP methods allowed for this folder. - description_get_one: Optional short description for the get-one route. - description_get_many: Optional short description for the list route. - description_search: Optional short description for the search route. - description_schema: Optional short description for the schema route. - """ + """Deprecated alias for :meth:`update_api_collection`.""" + warn_deprecated_method("update_api_folder", "update_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) payload: dict[str, Any] = {} @@ -673,12 +802,8 @@ def update_api_folder( return APIFolderSummary.model_validate(data) def remove_api_folder(self, api_key: APIRef, folder_key: FolderRef) -> None: - """Remove a folder from an API. - - Args: - api_key: Unique identifier of the API. - folder_key: Unique identifier of the folder to remove. - """ + """Deprecated alias for :meth:`remove_api_collection`.""" + warn_deprecated_method("remove_api_folder", "remove_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) self.request( @@ -1060,54 +1185,53 @@ def delete_flux_permission_object( ) # ------------------------------------------------------------------ # - # Folder operations + # Collection operations (canonical going forward) # ------------------------------------------------------------------ # - def list_folders(self, *, params: Mapping[str, Any] | None = None) -> FolderList: - """List all folders in the environment. + def list_collections( + self, *, params: Mapping[str, Any] | None = None + ) -> CollectionList: + """List all collections in the environment. Args: params: Optional query parameters for filtering/pagination. """ - path = f"{self._folders_tree_root()}/" - data = self.request("GET", path, params=params) - return FolderList.model_validate(data) + data = self.request("GET", f"{self._collections_tree_root()}/", params=params) + return CollectionList.model_validate(data) - def get_folder(self, folder_key: FolderRef) -> FolderSummary: - """Retrieve details for a specific folder by key. + def get_collection(self, collection_key: CollectionRef) -> CollectionSummary: + """Retrieve details for a specific collection by key. Args: - folder_key: Unique identifier of the folder. + collection_key: Unique identifier of the collection. """ - folder_key = _resolve_key(folder_key) + collection_key = _resolve_key(collection_key) data = self.request( - "GET", f"{self._folders_tree_item()}/", params={"key": folder_key} + "GET", f"{self._collections_tree_item()}/", params={"key": collection_key} ) - return FolderSummary.model_validate(data) + return CollectionSummary.model_validate(data) - def get_folder_by_path(self, path: str) -> FolderSummary: - """Retrieve details for a folder by its path. + def get_collection_by_path(self, path: str) -> CollectionSummary: + """Retrieve details for a collection by its hierarchical path. Args: - path: Hierarchical path to the folder (e.g., "parent/child"). + path: Hierarchical path to the collection (e.g., "parent/child"). """ data = self.request( - "GET", - f"{self._folders_tree_item()}/", - params={"path": path}, + "GET", f"{self._collections_tree_item()}/", params={"path": path} ) - return FolderSummary.model_validate(data) + return CollectionSummary.model_validate(data) - def list_folder_tree( + def list_collection_tree( self, *, key: str | None = None, mode: str | None = None, - ) -> FolderList: - """List folders as a hierarchical tree. + ) -> CollectionList: + """List collections as a hierarchical tree. Args: - key: Optional root folder key to start from. + key: Optional root collection key to start from. mode: Tree traversal mode. """ params: dict[str, Any] = {} @@ -1115,28 +1239,115 @@ def list_folder_tree( params["key"] = key if mode: params["mode"] = mode - path = f"{self._folders_tree_root()}/" - data = self.request("GET", path, params=params or None) - return FolderList.model_validate(data) + data = self.request( + "GET", f"{self._collections_tree_root()}/", params=params or None + ) + return CollectionList.model_validate(data) - def create_folder(self, payload: Mapping[str, Any]) -> FolderSummary: - """Create a new folder. + def create_collection(self, payload: Mapping[str, Any]) -> CollectionSummary: + """Create a new collection. + + Args: + payload: Collection configuration including name, alias, folder_type, and + content_type. JSON wire field names (``folder_type``, etc.) are + preserved for backwards compatibility. + """ + data = self.request( + "POST", f"{self._collections_tree_root()}/", json_body=payload + ) + return CollectionSummary.model_validate(data) + + def update_collection( + self, collection_key: CollectionRef, payload: Mapping[str, Any] + ) -> CollectionSummary: + """Update a collection's configuration. + + Args: + collection_key: Unique identifier of the collection. + payload: Fields to update. + """ + collection_key = _resolve_key(collection_key) + data = self.request( + "PUT", + f"{self._collections_tree_item()}/", + params={"key": collection_key}, + json_body=payload, + ) + return CollectionSummary.model_validate(data) + + def delete_collection(self, collection_key: CollectionRef) -> None: + """Delete a collection. Args: - payload: Folder configuration including name, alias, folder_type, and content_type. + collection_key: Unique identifier of the collection to delete. """ + collection_key = _resolve_key(collection_key) + self.request( + "DELETE", + f"{self._collections_tree_item()}/", + params={"key": collection_key}, + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # Folder operations (deprecated — hit the legacy /folders/ URL alias on + # the server; method body unchanged from pre-rename for wire-compat. The + # only behavioural change is a one-shot DeprecationWarning. Will be + # removed in 1.0; new code should use the Collection methods above.) + # ------------------------------------------------------------------ # + + def list_folders(self, *, params: Mapping[str, Any] | None = None) -> FolderList: + """Deprecated alias for :meth:`list_collections`. Hits /folders/ URL.""" + warn_deprecated_method("list_folders", "list_collections") + data = self.request("GET", f"{self._folders_tree_root()}/", params=params) + return FolderList.model_validate(data) + + def get_folder(self, folder_key: FolderRef) -> FolderSummary: + """Deprecated alias for :meth:`get_collection`. Hits /folders/ URL.""" + warn_deprecated_method("get_folder", "get_collection") + folder_key = _resolve_key(folder_key) + data = self.request( + "GET", f"{self._folders_tree_item()}/", params={"key": folder_key} + ) + return FolderSummary.model_validate(data) + + def get_folder_by_path(self, path: str) -> FolderSummary: + """Deprecated alias for :meth:`get_collection_by_path`. Hits /folders/ URL.""" + warn_deprecated_method("get_folder_by_path", "get_collection_by_path") + data = self.request( + "GET", f"{self._folders_tree_item()}/", params={"path": path} + ) + return FolderSummary.model_validate(data) + + def list_folder_tree( + self, + *, + key: str | None = None, + mode: str | None = None, + ) -> FolderList: + """Deprecated alias for :meth:`list_collection_tree`. Hits /folders/ URL.""" + warn_deprecated_method("list_folder_tree", "list_collection_tree") + params: dict[str, Any] = {} + if key: + params["key"] = key + if mode: + params["mode"] = mode + data = self.request( + "GET", f"{self._folders_tree_root()}/", params=params or None + ) + return FolderList.model_validate(data) + + def create_folder(self, payload: Mapping[str, Any]) -> FolderSummary: + """Deprecated alias for :meth:`create_collection`. Hits /folders/ URL.""" + warn_deprecated_method("create_folder", "create_collection") data = self.request("POST", f"{self._folders_tree_root()}/", json_body=payload) return FolderSummary.model_validate(data) def update_folder( self, folder_key: FolderRef, payload: Mapping[str, Any] ) -> FolderSummary: - """Update a folder's configuration. - - Args: - folder_key: Unique identifier of the folder. - payload: Fields to update. - """ + """Deprecated alias for :meth:`update_collection`. Hits /folders/ URL.""" + warn_deprecated_method("update_folder", "update_collection") folder_key = _resolve_key(folder_key) data = self.request( "PUT", @@ -1147,11 +1358,8 @@ def update_folder( return FolderSummary.model_validate(data) def delete_folder(self, folder_key: FolderRef) -> None: - """Delete a folder. - - Args: - folder_key: Unique identifier of the folder to delete. - """ + """Deprecated alias for :meth:`delete_collection`. Hits /folders/ URL.""" + warn_deprecated_method("delete_folder", "delete_collection") folder_key = _resolve_key(folder_key) self.request( "DELETE", @@ -1765,67 +1973,56 @@ def delete_component_field( ) # ------------------------------------------------------------------ # - # Collection folder schema operations + # Collection schema version operations (canonical) # ------------------------------------------------------------------ # - def list_folder_versions( + def list_collection_versions( self, - folder_key: FolderRef, + collection_key: CollectionRef, *, params: Mapping[str, Any] | None = None, ) -> SchemaVersionList: - """List all schema versions for a collection folder. - - Args: - folder_key: Unique identifier of the folder. - params: Optional query parameters for filtering/pagination. - """ - folder_key = _resolve_key(folder_key) + """List all schema versions for a collection.""" + collection_key = _resolve_key(collection_key) data = self.request( - "GET", f"{self._folder_versions_base(folder_key)}/", params=params + "GET", f"{self._collection_versions_base(collection_key)}/", params=params ) return SchemaVersionList.model_validate(data) - def create_folder_version( + def create_collection_version( self, - folder_key: FolderRef, + collection_key: CollectionRef, payload: Mapping[str, Any], *, copy_from: SchemaVersionRef | None = None, ) -> SchemaVersionSummary: - """Create a new schema version for a collection folder. + """Create a new schema version for a collection. Args: - folder_key: Unique identifier of the folder. + collection_key: Unique identifier of the collection. payload: Version configuration including name. copy_from: Optional version key to copy schema from. """ - folder_key = _resolve_key(folder_key) + collection_key = _resolve_key(collection_key) copy_from = _resolve_key(copy_from) if copy_from is not None else None params = {"copy_from": copy_from} if copy_from else None data = self.request( "POST", - f"{self._folder_versions_base(folder_key)}/", + f"{self._collection_versions_base(collection_key)}/", params=params, json_body=payload, ) return SchemaVersionSummary.model_validate(data) - def get_folder_version( + def get_collection_version( self, - folder_key: FolderRef, + collection_key: CollectionRef, version_key: SchemaVersionRef, *, include_schema: bool | None = None, ) -> SchemaVersionSummary: - """Retrieve details for a specific folder schema version. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - include_schema: Whether to include the full schema definition. - """ - folder_key = _resolve_key(folder_key) + """Retrieve details for a specific collection schema version.""" + collection_key = _resolve_key(collection_key) version_key = _resolve_key(version_key) params = ( {"include_schema": str(include_schema).lower()} @@ -1834,84 +2031,332 @@ def get_folder_version( ) data = self.request( "GET", - f"{self._folder_versions_base(folder_key)}/{version_key}/", + f"{self._collection_versions_base(collection_key)}/{version_key}/", params=params, ) return SchemaVersionSummary.model_validate(data) - def update_folder_version( + def update_collection_version( self, - folder_key: FolderRef, + collection_key: CollectionRef, version_key: SchemaVersionRef, payload: Mapping[str, Any], ) -> SchemaVersionSummary: - """Update a folder schema version's configuration. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - payload: Fields to update. - """ - folder_key = _resolve_key(folder_key) + """Update a collection schema version's configuration.""" + collection_key = _resolve_key(collection_key) version_key = _resolve_key(version_key) data = self.request( "PUT", - f"{self._folder_versions_base(folder_key)}/{version_key}/", + f"{self._collection_versions_base(collection_key)}/{version_key}/", json_body=payload, ) return SchemaVersionSummary.model_validate(data) - def delete_folder_version( - self, folder_key: FolderRef, version_key: SchemaVersionRef + def delete_collection_version( + self, collection_key: CollectionRef, version_key: SchemaVersionRef ) -> None: - """Delete a folder schema version. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version to delete. - """ - folder_key = _resolve_key(folder_key) + """Delete a collection schema version.""" + collection_key = _resolve_key(collection_key) version_key = _resolve_key(version_key) self.request( "DELETE", - f"{self._folder_versions_base(folder_key)}/{version_key}/", + f"{self._collection_versions_base(collection_key)}/{version_key}/", parse_json=False, ) - def publish_folder_version( + def publish_collection_version( self, - folder_key: FolderRef, + collection_key: CollectionRef, version_key: SchemaVersionRef, ) -> SchemaVersionSummary: - """Publish a folder schema version, making it active for the folder. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version to publish. - """ - folder_key = _resolve_key(folder_key) + """Publish a collection schema version, making it active.""" + collection_key = _resolve_key(collection_key) version_key = _resolve_key(version_key) data = self.request( "POST", - f"{self._folder_versions_base(folder_key)}/{version_key}/publish/", + f"{self._collection_versions_base(collection_key)}/{version_key}/publish/", ) return SchemaVersionSummary.model_validate(data) - def list_folder_fields( + def sync_collection_component( self, - folder_key: FolderRef, - version_key: SchemaVersionRef, + collection_key: CollectionRef, *, - params: Mapping[str, Any] | None = None, - ) -> FieldList: - """List all fields in a folder schema version. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - params: Optional query parameters for filtering. + field_paths: Sequence[str] | None = None, + to_versions: Mapping[str, str] | None = None, + ) -> SyncComponentResponse: + """Advance pinned nested fields on a Collection to a target Component version. + + Creates a new published Collection schema version with the + affected ``meta.component_version`` pins advanced. ``auto_update=True`` + fields and fields already at the target version are reported in + ``skipped`` rather than advanced. + + Parameters + ---------- + collection_key: + The Collection to sync. + field_paths: + Optional list of nested field paths to advance. When omitted, + every pinned (``auto_update=False``) nested field on the + Collection's current version is considered. + to_versions: + Optional per-path override mapping field path → target + Component Version UID. Paths not in this mapping advance to + the referenced Component's ``current_version``. When both + ``field_paths`` and ``to_versions`` are supplied, every key in + ``to_versions`` must also appear in ``field_paths``. + + Returns + ------- + SyncComponentResponse + Summary of synced and skipped paths, plus the new + ``schema_version`` UID (``None`` if no paths were advanced). + + Raises + ------ + ValueError + Locally raised before any HTTP request when both ``field_paths`` + and ``to_versions`` are supplied AND ``to_versions`` contains a + key that is not in ``field_paths``. Mirrors the server-side + validator so the misuse surfaces at the call-site. + FoxnoseAPIError + On 409 ``component_sync_conflict`` when the target version + would break existing resources, on 422 ``too_many_versions`` + when the Collection's schema quota is exhausted, on 404 when + the Collection or a specified target version is missing, or + on 422 ``validation_error`` when the request body is invalid. """ - folder_key = _resolve_key(folder_key) + collection_key = _resolve_key(collection_key) + # Client-side invariant (mirrors server validator): every key in + # to_versions must also appear in field_paths when both are supplied. + # Catch the misuse locally instead of round-tripping a 422. + if field_paths is not None and to_versions: + extras = set(to_versions.keys()) - set(field_paths) + if extras: + raise ValueError( + "to_versions includes paths not present in field_paths: " + f"{sorted(extras)}" + ) + body: dict[str, Any] = {} + if field_paths is not None: + body["field_paths"] = list(field_paths) + if to_versions is not None: + body["to_versions"] = dict(to_versions) + data = self.request( + "POST", + f"{self._collection_sync_component(collection_key)}/", + json_body=body, + ) + return SyncComponentResponse.model_validate(data) + + # ------------------------------------------------------------------ # + # Folder schema version operations (deprecated) + # ------------------------------------------------------------------ # + + def list_folder_versions( + self, + folder_key: FolderRef, + *, + params: Mapping[str, Any] | None = None, + ) -> SchemaVersionList: + """Deprecated alias for :meth:`list_collection_versions`.""" + warn_deprecated_method("list_folder_versions", "list_collection_versions") + folder_key = _resolve_key(folder_key) + data = self.request( + "GET", f"{self._folder_versions_base(folder_key)}/", params=params + ) + return SchemaVersionList.model_validate(data) + + def create_folder_version( + self, + folder_key: FolderRef, + payload: Mapping[str, Any], + *, + copy_from: SchemaVersionRef | None = None, + ) -> SchemaVersionSummary: + """Deprecated alias for :meth:`create_collection_version`.""" + warn_deprecated_method("create_folder_version", "create_collection_version") + folder_key = _resolve_key(folder_key) + copy_from = _resolve_key(copy_from) if copy_from is not None else None + params = {"copy_from": copy_from} if copy_from else None + data = self.request( + "POST", + f"{self._folder_versions_base(folder_key)}/", + params=params, + json_body=payload, + ) + return SchemaVersionSummary.model_validate(data) + + def get_folder_version( + self, + folder_key: FolderRef, + version_key: SchemaVersionRef, + *, + include_schema: bool | None = None, + ) -> SchemaVersionSummary: + """Deprecated alias for :meth:`get_collection_version`.""" + warn_deprecated_method("get_folder_version", "get_collection_version") + folder_key = _resolve_key(folder_key) + version_key = _resolve_key(version_key) + params = ( + {"include_schema": str(include_schema).lower()} + if include_schema is not None + else None + ) + data = self.request( + "GET", + f"{self._folder_versions_base(folder_key)}/{version_key}/", + params=params, + ) + return SchemaVersionSummary.model_validate(data) + + def update_folder_version( + self, + folder_key: FolderRef, + version_key: SchemaVersionRef, + payload: Mapping[str, Any], + ) -> SchemaVersionSummary: + """Deprecated alias for :meth:`update_collection_version`.""" + warn_deprecated_method("update_folder_version", "update_collection_version") + folder_key = _resolve_key(folder_key) + version_key = _resolve_key(version_key) + data = self.request( + "PUT", + f"{self._folder_versions_base(folder_key)}/{version_key}/", + json_body=payload, + ) + return SchemaVersionSummary.model_validate(data) + + def delete_folder_version( + self, folder_key: FolderRef, version_key: SchemaVersionRef + ) -> None: + """Deprecated alias for :meth:`delete_collection_version`.""" + warn_deprecated_method("delete_folder_version", "delete_collection_version") + folder_key = _resolve_key(folder_key) + version_key = _resolve_key(version_key) + self.request( + "DELETE", + f"{self._folder_versions_base(folder_key)}/{version_key}/", + parse_json=False, + ) + + def publish_folder_version( + self, + folder_key: FolderRef, + version_key: SchemaVersionRef, + ) -> SchemaVersionSummary: + """Deprecated alias for :meth:`publish_collection_version`.""" + warn_deprecated_method("publish_folder_version", "publish_collection_version") + folder_key = _resolve_key(folder_key) + version_key = _resolve_key(version_key) + data = self.request( + "POST", + f"{self._folder_versions_base(folder_key)}/{version_key}/publish/", + ) + return SchemaVersionSummary.model_validate(data) + + # ------------------------------------------------------------------ # + # Collection schema field operations (canonical) + # ------------------------------------------------------------------ # + + def list_collection_fields( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + *, + params: Mapping[str, Any] | None = None, + ) -> FieldList: + """List all fields in a collection schema version.""" + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = self.request( + "GET", + f"{self._collection_schema_tree(collection_key, version_key)}/", + params=params, + ) + return FieldList.model_validate(data) + + def create_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + payload: Mapping[str, Any], + ) -> FieldSummary: + """Add a new field to a collection schema version.""" + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = self.request( + "POST", + f"{self._collection_schema_tree(collection_key, version_key)}/", + json_body=payload, + ) + return FieldSummary.model_validate(data) + + def get_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + ) -> FieldSummary: + """Retrieve details for a specific field in a collection schema.""" + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = self.request( + "GET", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + ) + return FieldSummary.model_validate(data) + + def update_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + payload: Mapping[str, Any], + ) -> FieldSummary: + """Update a field in a collection schema.""" + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = self.request( + "PUT", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + json_body=payload, + ) + return FieldSummary.model_validate(data) + + def delete_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + ) -> None: + """Delete a field from a collection schema.""" + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + self.request( + "DELETE", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # Folder schema field operations (deprecated) + # ------------------------------------------------------------------ # + + def list_folder_fields( + self, + folder_key: FolderRef, + version_key: SchemaVersionRef, + *, + params: Mapping[str, Any] | None = None, + ) -> FieldList: + """Deprecated alias for :meth:`list_collection_fields`.""" + warn_deprecated_method("list_folder_fields", "list_collection_fields") + folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = self.request( "GET", @@ -1926,13 +2371,8 @@ def create_folder_field( version_key: SchemaVersionRef, payload: Mapping[str, Any], ) -> FieldSummary: - """Add a new field to a folder schema version. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - payload: Field configuration including name and type. - """ + """Deprecated alias for :meth:`create_collection_field`.""" + warn_deprecated_method("create_folder_field", "create_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = self.request( @@ -1948,13 +2388,8 @@ def get_folder_field( version_key: SchemaVersionRef, field_path: str, ) -> FieldSummary: - """Retrieve details for a specific field in a folder schema. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - field_path: Path to the field (e.g., "title" or "metadata.author"). - """ + """Deprecated alias for :meth:`get_collection_field`.""" + warn_deprecated_method("get_folder_field", "get_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = self.request( @@ -1971,14 +2406,8 @@ def update_folder_field( field_path: str, payload: Mapping[str, Any], ) -> FieldSummary: - """Update a field in a folder schema. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - field_path: Path to the field. - payload: Fields to update. - """ + """Deprecated alias for :meth:`update_collection_field`.""" + warn_deprecated_method("update_folder_field", "update_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = self.request( @@ -1992,13 +2421,8 @@ def update_folder_field( def delete_folder_field( self, folder_key: FolderRef, version_key: SchemaVersionRef, field_path: str ) -> None: - """Delete a field from a folder schema. - - Args: - folder_key: Unique identifier of the folder. - version_key: Unique identifier of the version. - field_path: Path to the field to delete. - """ + """Deprecated alias for :meth:`delete_collection_field`.""" + warn_deprecated_method("delete_folder_field", "delete_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) self.request( @@ -2035,7 +2459,6 @@ def create_resource( folder_key: FolderRef, payload: Mapping[str, Any], *, - component: ComponentRef | None = None, external_id: str | None = None, ) -> ResourceSummary: """ @@ -2043,21 +2466,17 @@ def create_resource( Args: folder_key: Target folder key. - payload: JSON payload that matches the folder/component schema. - component: Optional component key for component-based folders. + payload: JSON payload that matches the folder schema. external_id: Optional external identifier for the resource. """ folder_key = _resolve_key(folder_key) - component = _resolve_key(component) if component is not None else None - params = {"component": component} if component else None body: dict[str, Any] = dict(payload) if external_id is not None: body["external_id"] = external_id data = self.request( "POST", f"{self._resource_base(folder_key)}/", - params=params, json_body=body, ) return ResourceSummary.model_validate(data) @@ -2068,7 +2487,6 @@ def upsert_resource( payload: Mapping[str, Any], *, external_id: str, - component: ComponentRef | None = None, ) -> ResourceSummary: """ Create or update a resource by external_id. @@ -2079,15 +2497,11 @@ def upsert_resource( Args: folder_key: Target folder key. - payload: JSON payload matching the folder/component schema. + payload: JSON payload matching the folder schema. external_id: External identifier for the resource (required). - component: Optional component key for component-based folders. """ folder_key = _resolve_key(folder_key) - component = _resolve_key(component) if component is not None else None params: dict[str, str] = {"external_id": external_id} - if component: - params["component"] = component data = self.request( "PUT", f"{self._resource_base(folder_key)}/", @@ -2144,7 +2558,6 @@ def batch_upsert_resources( folder_key, item.payload, external_id=item.external_id, - component=item.component, ): (idx, item) for idx, item in enumerate(items) } @@ -2581,9 +2994,108 @@ async def delete_api(self, api_key: APIRef) -> None: api_key = _resolve_key(api_key) await self.request("DELETE", f"{self._api_root(api_key)}/", parse_json=False) + # ------------------------------------------------------------------ # + # API ↔ Collection association (canonical) + # ------------------------------------------------------------------ # + + async def list_api_collections( + self, api_key: APIRef, *, params: Mapping[str, Any] | None = None + ) -> APICollectionList: + api_key = _resolve_key(api_key) + data = await self.request( + "GET", f"{self._api_collections_root(api_key)}/", params=params + ) + return APICollectionList.model_validate(data) + + async def add_api_collection( + self, + api_key: APIRef, + collection_key: CollectionRef, + *, + allowed_methods: list[str] | None = None, + description_get_one: str | None = None, + description_get_many: str | None = None, + description_search: str | None = None, + description_schema: str | None = None, + ) -> APICollectionSummary: + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + payload: dict[str, Any] = {"folder": collection_key} + if allowed_methods is not None: + payload["allowed_methods"] = allowed_methods + if description_get_one is not None: + payload["description_get_one"] = description_get_one + if description_get_many is not None: + payload["description_get_many"] = description_get_many + if description_search is not None: + payload["description_search"] = description_search + if description_schema is not None: + payload["description_schema"] = description_schema + data = await self.request( + "POST", f"{self._api_collections_root(api_key)}/", json_body=payload + ) + return APICollectionSummary.model_validate(data) + + async def get_api_collection( + self, api_key: APIRef, collection_key: CollectionRef + ) -> APICollectionSummary: + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + data = await self.request( + "GET", f"{self._api_collections_root(api_key)}/{collection_key}/" + ) + return APICollectionSummary.model_validate(data) + + async def update_api_collection( + self, + api_key: APIRef, + collection_key: CollectionRef, + *, + allowed_methods: list[str] | None = None, + description_get_one: str | None = None, + description_get_many: str | None = None, + description_search: str | None = None, + description_schema: str | None = None, + ) -> APICollectionSummary: + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + payload: dict[str, Any] = {} + if allowed_methods is not None: + payload["allowed_methods"] = allowed_methods + if description_get_one is not None: + payload["description_get_one"] = description_get_one + if description_get_many is not None: + payload["description_get_many"] = description_get_many + if description_search is not None: + payload["description_search"] = description_search + if description_schema is not None: + payload["description_schema"] = description_schema + data = await self.request( + "PUT", + f"{self._api_collections_root(api_key)}/{collection_key}/", + json_body=payload, + ) + return APICollectionSummary.model_validate(data) + + async def remove_api_collection( + self, api_key: APIRef, collection_key: CollectionRef + ) -> None: + api_key = _resolve_key(api_key) + collection_key = _resolve_key(collection_key) + await self.request( + "DELETE", + f"{self._api_collections_root(api_key)}/{collection_key}/", + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # API ↔ Folder association (deprecated) + # ------------------------------------------------------------------ # + async def list_api_folders( self, api_key: APIRef, *, params: Mapping[str, Any] | None = None ) -> APIFolderList: + warn_deprecated_method("list_api_folders", "list_api_collections") api_key = _resolve_key(api_key) data = await self.request( "GET", f"{self._api_folders_root(api_key)}/", params=params @@ -2601,6 +3113,7 @@ async def add_api_folder( description_search: str | None = None, description_schema: str | None = None, ) -> APIFolderSummary: + warn_deprecated_method("add_api_folder", "add_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) payload: dict[str, Any] = {"folder": folder_key} @@ -2622,6 +3135,7 @@ async def add_api_folder( async def get_api_folder( self, api_key: APIRef, folder_key: FolderRef ) -> APIFolderSummary: + warn_deprecated_method("get_api_folder", "get_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) data = await self.request( @@ -2640,6 +3154,7 @@ async def update_api_folder( description_search: str | None = None, description_schema: str | None = None, ) -> APIFolderSummary: + warn_deprecated_method("update_api_folder", "update_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) payload: dict[str, Any] = {} @@ -2659,6 +3174,7 @@ async def update_api_folder( return APIFolderSummary.model_validate(data) async def remove_api_folder(self, api_key: APIRef, folder_key: FolderRef) -> None: + warn_deprecated_method("remove_api_folder", "remove_api_collection") api_key = _resolve_key(api_key) folder_key = _resolve_key(folder_key) await self.request( @@ -2922,16 +3438,86 @@ async def delete_flux_permission_object( ) # ------------------------------------------------------------------ # - # Folder operations + # Collection operations (canonical) + # ------------------------------------------------------------------ # + + async def list_collections( + self, *, params: Mapping[str, Any] | None = None + ) -> CollectionList: + data = await self.request( + "GET", f"{self._collections_tree_root()}/", params=params + ) + return CollectionList.model_validate(data) + + async def get_collection(self, collection_key: CollectionRef) -> CollectionSummary: + collection_key = _resolve_key(collection_key) + data = await self.request( + "GET", f"{self._collections_tree_item()}/", params={"key": collection_key} + ) + return CollectionSummary.model_validate(data) + + async def get_collection_by_path(self, path: str) -> CollectionSummary: + data = await self.request( + "GET", f"{self._collections_tree_item()}/", params={"path": path} + ) + return CollectionSummary.model_validate(data) + + async def list_collection_tree( + self, + *, + key: str | None = None, + mode: str | None = None, + ) -> CollectionList: + params: dict[str, Any] = {} + if key: + params["key"] = key + if mode: + params["mode"] = mode + data = await self.request( + "GET", f"{self._collections_tree_root()}/", params=params or None + ) + return CollectionList.model_validate(data) + + async def create_collection(self, payload: Mapping[str, Any]) -> CollectionSummary: + data = await self.request( + "POST", f"{self._collections_tree_root()}/", json_body=payload + ) + return CollectionSummary.model_validate(data) + + async def update_collection( + self, collection_key: CollectionRef, payload: Mapping[str, Any] + ) -> CollectionSummary: + collection_key = _resolve_key(collection_key) + data = await self.request( + "PUT", + f"{self._collections_tree_item()}/", + params={"key": collection_key}, + json_body=payload, + ) + return CollectionSummary.model_validate(data) + + async def delete_collection(self, collection_key: CollectionRef) -> None: + collection_key = _resolve_key(collection_key) + await self.request( + "DELETE", + f"{self._collections_tree_item()}/", + params={"key": collection_key}, + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # Folder operations (deprecated) # ------------------------------------------------------------------ # async def list_folders( self, *, params: Mapping[str, Any] | None = None ) -> FolderList: + warn_deprecated_method("list_folders", "list_collections") data = await self.request("GET", f"{self._folders_tree_root()}/", params=params) return FolderList.model_validate(data) async def get_folder(self, folder_key: FolderRef) -> FolderSummary: + warn_deprecated_method("get_folder", "get_collection") folder_key = _resolve_key(folder_key) data = await self.request( "GET", f"{self._folders_tree_item()}/", params={"key": folder_key} @@ -2939,6 +3525,7 @@ async def get_folder(self, folder_key: FolderRef) -> FolderSummary: return FolderSummary.model_validate(data) async def get_folder_by_path(self, path: str) -> FolderSummary: + warn_deprecated_method("get_folder_by_path", "get_collection_by_path") data = await self.request( "GET", f"{self._folders_tree_item()}/", @@ -2952,6 +3539,7 @@ async def list_folder_tree( key: str | None = None, mode: str | None = None, ) -> FolderList: + warn_deprecated_method("list_folder_tree", "list_collection_tree") params: dict[str, Any] = {} if key: params["key"] = key @@ -2963,6 +3551,7 @@ async def list_folder_tree( return FolderList.model_validate(data) async def create_folder(self, payload: Mapping[str, Any]) -> FolderSummary: + warn_deprecated_method("create_folder", "create_collection") data = await self.request( "POST", f"{self._folders_tree_root()}/", json_body=payload ) @@ -2971,6 +3560,7 @@ async def create_folder(self, payload: Mapping[str, Any]) -> FolderSummary: async def update_folder( self, folder_key: FolderRef, payload: Mapping[str, Any] ) -> FolderSummary: + warn_deprecated_method("update_folder", "update_collection") folder_key = _resolve_key(folder_key) data = await self.request( "PUT", @@ -2981,6 +3571,7 @@ async def update_folder( return FolderSummary.model_validate(data) async def delete_folder(self, folder_key: FolderRef) -> None: + warn_deprecated_method("delete_folder", "delete_collection") folder_key = _resolve_key(folder_key) await self.request( "DELETE", @@ -3195,12 +3786,223 @@ async def delete_component_field( parse_json=False, ) + # ------------------------------------------------------------------ # + # Collection schema version operations (canonical) + # ------------------------------------------------------------------ # + + async def list_collection_versions( + self, + collection_key: CollectionRef, + *, + params: Mapping[str, Any] | None = None, + ) -> SchemaVersionList: + collection_key = _resolve_key(collection_key) + data = await self.request( + "GET", + f"{self._collection_versions_base(collection_key)}/", + params=params, + ) + return SchemaVersionList.model_validate(data) + + async def create_collection_version( + self, + collection_key: CollectionRef, + payload: Mapping[str, Any], + *, + copy_from: SchemaVersionRef | None = None, + ) -> SchemaVersionSummary: + collection_key = _resolve_key(collection_key) + copy_from = _resolve_key(copy_from) if copy_from is not None else None + params = {"copy_from": copy_from} if copy_from else None + data = await self.request( + "POST", + f"{self._collection_versions_base(collection_key)}/", + params=params, + json_body=payload, + ) + return SchemaVersionSummary.model_validate(data) + + async def get_collection_version( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + *, + include_schema: bool | None = None, + ) -> SchemaVersionSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + params = ( + {"include_schema": str(include_schema).lower()} + if include_schema is not None + else None + ) + data = await self.request( + "GET", + f"{self._collection_versions_base(collection_key)}/{version_key}/", + params=params, + ) + return SchemaVersionSummary.model_validate(data) + + async def update_collection_version( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + payload: Mapping[str, Any], + ) -> SchemaVersionSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "PUT", + f"{self._collection_versions_base(collection_key)}/{version_key}/", + json_body=payload, + ) + return SchemaVersionSummary.model_validate(data) + + async def delete_collection_version( + self, collection_key: CollectionRef, version_key: SchemaVersionRef + ) -> None: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + await self.request( + "DELETE", + f"{self._collection_versions_base(collection_key)}/{version_key}/", + parse_json=False, + ) + + async def publish_collection_version( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + ) -> SchemaVersionSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "POST", + f"{self._collection_versions_base(collection_key)}/{version_key}/publish/", + ) + return SchemaVersionSummary.model_validate(data) + + async def sync_collection_component( + self, + collection_key: CollectionRef, + *, + field_paths: Sequence[str] | None = None, + to_versions: Mapping[str, str] | None = None, + ) -> SyncComponentResponse: + """Async sibling of :meth:`ManagementClient.sync_collection_component`.""" + collection_key = _resolve_key(collection_key) + if field_paths is not None and to_versions: + extras = set(to_versions.keys()) - set(field_paths) + if extras: + raise ValueError( + "to_versions includes paths not present in field_paths: " + f"{sorted(extras)}" + ) + body: dict[str, Any] = {} + if field_paths is not None: + body["field_paths"] = list(field_paths) + if to_versions is not None: + body["to_versions"] = dict(to_versions) + data = await self.request( + "POST", + f"{self._collection_sync_component(collection_key)}/", + json_body=body, + ) + return SyncComponentResponse.model_validate(data) + + # ------------------------------------------------------------------ # + # Collection schema field operations (canonical) + # ------------------------------------------------------------------ # + + async def list_collection_fields( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + *, + params: Mapping[str, Any] | None = None, + ) -> FieldList: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "GET", + f"{self._collection_schema_tree(collection_key, version_key)}/", + params=params, + ) + return FieldList.model_validate(data) + + async def create_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + payload: Mapping[str, Any], + ) -> FieldSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "POST", + f"{self._collection_schema_tree(collection_key, version_key)}/", + json_body=payload, + ) + return FieldSummary.model_validate(data) + + async def get_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + ) -> FieldSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "GET", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + ) + return FieldSummary.model_validate(data) + + async def update_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + payload: Mapping[str, Any], + ) -> FieldSummary: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + data = await self.request( + "PUT", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + json_body=payload, + ) + return FieldSummary.model_validate(data) + + async def delete_collection_field( + self, + collection_key: CollectionRef, + version_key: SchemaVersionRef, + field_path: str, + ) -> None: + collection_key = _resolve_key(collection_key) + version_key = _resolve_key(version_key) + await self.request( + "DELETE", + f"{self._collection_schema_tree(collection_key, version_key)}/field/", + params={"path": field_path}, + parse_json=False, + ) + + # ------------------------------------------------------------------ # + # Folder schema version operations (deprecated) + # ------------------------------------------------------------------ # + async def list_folder_versions( self, folder_key: FolderRef, *, params: Mapping[str, Any] | None = None, ) -> SchemaVersionList: + warn_deprecated_method("list_folder_versions", "list_collection_versions") folder_key = _resolve_key(folder_key) data = await self.request( "GET", f"{self._folder_versions_base(folder_key)}/", params=params @@ -3214,6 +4016,7 @@ async def create_folder_version( *, copy_from: SchemaVersionRef | None = None, ) -> SchemaVersionSummary: + warn_deprecated_method("create_folder_version", "create_collection_version") folder_key = _resolve_key(folder_key) copy_from = _resolve_key(copy_from) if copy_from is not None else None params = {"copy_from": copy_from} if copy_from else None @@ -3232,6 +4035,7 @@ async def get_folder_version( *, include_schema: bool | None = None, ) -> SchemaVersionSummary: + warn_deprecated_method("get_folder_version", "get_collection_version") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) params = ( @@ -3252,6 +4056,7 @@ async def update_folder_version( version_key: SchemaVersionRef, payload: Mapping[str, Any], ) -> SchemaVersionSummary: + warn_deprecated_method("update_folder_version", "update_collection_version") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3264,6 +4069,7 @@ async def update_folder_version( async def delete_folder_version( self, folder_key: FolderRef, version_key: SchemaVersionRef ) -> None: + warn_deprecated_method("delete_folder_version", "delete_collection_version") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) await self.request( @@ -3277,6 +4083,7 @@ async def publish_folder_version( folder_key: FolderRef, version_key: SchemaVersionRef, ) -> SchemaVersionSummary: + warn_deprecated_method("publish_folder_version", "publish_collection_version") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3285,6 +4092,10 @@ async def publish_folder_version( ) return SchemaVersionSummary.model_validate(data) + # ------------------------------------------------------------------ # + # Folder schema field operations (deprecated) + # ------------------------------------------------------------------ # + async def list_folder_fields( self, folder_key: FolderRef, @@ -3292,6 +4103,7 @@ async def list_folder_fields( *, params: Mapping[str, Any] | None = None, ) -> FieldList: + warn_deprecated_method("list_folder_fields", "list_collection_fields") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3307,6 +4119,7 @@ async def create_folder_field( version_key: SchemaVersionRef, payload: Mapping[str, Any], ) -> FieldSummary: + warn_deprecated_method("create_folder_field", "create_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3322,6 +4135,7 @@ async def get_folder_field( version_key: SchemaVersionRef, field_path: str, ) -> FieldSummary: + warn_deprecated_method("get_folder_field", "get_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3338,6 +4152,7 @@ async def update_folder_field( field_path: str, payload: Mapping[str, Any], ) -> FieldSummary: + warn_deprecated_method("update_folder_field", "update_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) data = await self.request( @@ -3351,6 +4166,7 @@ async def update_folder_field( async def delete_folder_field( self, folder_key: FolderRef, version_key: SchemaVersionRef, field_path: str ) -> None: + warn_deprecated_method("delete_folder_field", "delete_collection_field") folder_key = _resolve_key(folder_key) version_key = _resolve_key(version_key) await self.request( @@ -3579,19 +4395,15 @@ async def create_resource( folder_key: FolderRef, payload: Mapping[str, Any], *, - component: ComponentRef | None = None, external_id: str | None = None, ) -> ResourceSummary: folder_key = _resolve_key(folder_key) - component = _resolve_key(component) if component is not None else None - params = {"component": component} if component else None body: dict[str, Any] = dict(payload) if external_id is not None: body["external_id"] = external_id data = await self.request( "POST", f"{self._resource_base(folder_key)}/", - params=params, json_body=body, ) return ResourceSummary.model_validate(data) @@ -3602,7 +4414,6 @@ async def upsert_resource( payload: Mapping[str, Any], *, external_id: str, - component: ComponentRef | None = None, ) -> ResourceSummary: """ Create or update a resource by external_id. @@ -3613,15 +4424,11 @@ async def upsert_resource( Args: folder_key: Target folder key. - payload: JSON payload matching the folder/component schema. + payload: JSON payload matching the folder schema. external_id: External identifier for the resource (required). - component: Optional component key for component-based folders. """ folder_key = _resolve_key(folder_key) - component = _resolve_key(component) if component is not None else None params: dict[str, str] = {"external_id": external_id} - if component: - params["component"] = component data = await self.request( "PUT", f"{self._resource_base(folder_key)}/", @@ -3681,7 +4488,6 @@ async def _process(index: int, item: BatchUpsertItem) -> None: folder_key, item.payload, external_id=item.external_id, - component=item.component, ) succeeded.append(result) except Exception as exc: diff --git a/src/foxnose_sdk/management/models.py b/src/foxnose_sdk/management/models.py index 75387ca..e4fb3b1 100644 --- a/src/foxnose_sdk/management/models.py +++ b/src/foxnose_sdk/management/models.py @@ -26,7 +26,6 @@ class ResourceSummary(BaseModel): created_at: datetime vectors_size: int name: str | None = None - component: str | None = None resource_owner: str | None = None current_revision: str | None = None external_id: str | None = None @@ -57,7 +56,6 @@ class FolderSummary(BaseModel): key: str name: str alias: str - folder_type: str content_type: str strict_reference: bool created_at: datetime @@ -68,6 +66,10 @@ class FolderSummary(BaseModel): FolderList = PaginatedResponse[FolderSummary] +# Collection-named aliases — same model, preferred name in new code. +CollectionSummary = FolderSummary +CollectionList = FolderList + class ComponentSummary(BaseModel): """Metadata describing a reusable component schema.""" @@ -123,6 +125,100 @@ class FieldSummary(BaseModel): FieldList = PaginatedResponse[FieldSummary] +class NestedFieldMeta(BaseModel): + """Helper for building the ``meta`` block of a Collection ``nested`` field. + + A ``nested`` field on a Collection schema embeds a Component schema at + a specific published version. The pin is required: even in auto-update + mode, ``component_version`` stores the most recently resolved version + and acts as a fallback if a future Component publish fails compatibility. + + Use ``.to_meta()`` (or just unpack via ``model_dump()``) when building + the ``meta`` payload for :meth:`ManagementClient.create_collection_field` + or :meth:`ManagementClient.update_collection_field`. + + Example + ------- + >>> meta = NestedFieldMeta( + ... component="cmp-seo-metadata", + ... component_version="ver-abc12345", + ... auto_update=True, + ... ) + >>> client.create_collection_field( + ... "articles", + ... "v1-draft", + ... {"key": "seo", "name": "SEO", "type": "nested", + ... "required": True, "meta": meta.to_meta()}, + ... ) + """ + + model_config = ConfigDict(extra="allow") + + component: str = Field(min_length=6, max_length=36) + component_version: str = Field(min_length=6, max_length=36) + auto_update: bool = False + + def to_meta(self) -> dict[str, Any]: + """Return the dict shape expected by the schema-tree endpoint.""" + return self.model_dump() + + +class SyncComponentSkippedItem(BaseModel): + """One entry in the ``skipped`` list of a sync_component response. + + Attributes + ---------- + path: + The nested field path that was skipped. + reason: + Why the field was skipped. One of: ``"not_requested"``, + ``"auto_update_mode"``, ``"already_at_target"``, + ``"component_not_found"``, ``"component_unpublished"``. + """ + + model_config = ConfigDict(extra="allow") + + path: str + reason: str + + +class SyncComponentResponse(BaseModel): + """Response payload from ``POST /collections/{key}/sync_component/``. + + Attributes + ---------- + synced_paths: + Field paths whose ``meta.component_version`` was advanced. Empty + when every requested path was skipped (no new schema version was + created in that case and ``schema_version`` is ``None``). + skipped: + Per-path skip records explaining why each was left alone. + schema_version: + UID of the newly published Collection schema version that the + sync materialized. ``None`` when no advance was needed. + """ + + model_config = ConfigDict(extra="allow") + + synced_paths: list[str] = Field(default_factory=list) + skipped: list[SyncComponentSkippedItem] = Field(default_factory=list) + schema_version: str | None = None + + +class ComponentSyncConflictDetail(BaseModel): + """One structured conflict entry inside a 409 ``component_sync_conflict`` + error detail (e.g. ``{"field_path": "...", "blocking_reason": "..."}``). + + ``blocking_reason`` is one of: ``"required_field_added_no_default"``, + ``"type_narrowed"``, ``"field_removed"``. + """ + + model_config = ConfigDict(extra="allow") + + field_path: str + blocking_reason: str + + class RegionInfo(BaseModel): """Represents a provisioned region for projects.""" @@ -239,12 +335,12 @@ class RolePermission(BaseModel): content_type: str actions: list[str] - all_objects: bool + all_objects: bool | None = None objects: list[str] | None = None class RolePermissionObject(BaseModel): - """Object-level scope entry for folder-items permissions.""" + """Object-level scope entry for an object-based permission.""" content_type: str object_key: str @@ -306,6 +402,10 @@ class APIFolderSummary(BaseModel): APIFolderList = PaginatedResponse[APIFolderSummary] +# Collection-named aliases. +APICollectionSummary = APIFolderSummary +APICollectionList = APIFolderList + class OrganizationOwner(BaseModel): """Owner metadata embedded into organization responses.""" @@ -438,9 +538,10 @@ class OrganizationUsage(BaseModel): class BatchUpsertItem(BaseModel): """A single item to upsert in a batch operation.""" + model_config = ConfigDict(extra="forbid") + external_id: str payload: dict[str, Any] - component: str | None = None class BatchItemError(BaseModel): diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index e8c8ff3..a23f332 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -70,7 +70,6 @@ "created_at": "2024-01-10T00:00:00Z", "vectors_size": 0, "name": None, - "component": None, "resource_owner": None, "current_revision": "rev-1", "external_id": None, @@ -232,13 +231,13 @@ } ROLE_PERMISSION_JSON = { - "content_type": "resources", - "actions": ["read", "update"], + "content_type": "collection-items", + "actions": ["read"], "all_objects": True, } PERMISSION_OBJECT_JSON = { - "content_type": "folder-items", + "content_type": "collection-items", "object_key": "folder-1", } @@ -483,7 +482,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_management_api_key_lifecycle(): - captured: dict[str, Any] = {"paths": []} + captured: dict[str, Any] = {"paths": [], "bodies": []} def handler(request: httpx.Request) -> httpx.Response: captured["paths"].append((request.method, request.url.path)) @@ -496,6 +495,7 @@ def handler(request: httpx.Request) -> httpx.Response: } return httpx.Response(200, json=payload) if request.method == "POST": + captured["bodies"].append(json.loads(request.content.decode())) return httpx.Response(201, json=MANAGEMENT_API_KEY_JSON) if request.method == "GET": return httpx.Response(200, json=MANAGEMENT_API_KEY_JSON) @@ -511,8 +511,11 @@ def handler(request: httpx.Request) -> httpx.Response: keys = await client.list_management_api_keys() assert keys.results[0].public_key == "manage_pub_abc" - created = await client.create_management_api_key({"description": "Ops key"}) + created = await client.create_management_api_key( + {"description": "Ops key", "role": "role-1"} + ) assert created.secret_key == "manage_sec_xyz" + assert captured["bodies"][0] == {"description": "Ops key", "role": "role-1"} detail = await client.get_management_api_key("api-key-1") assert detail.key == "api-key-1" @@ -529,7 +532,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_flux_api_key_lifecycle(): - captured: dict[str, Any] = {"paths": []} + captured: dict[str, Any] = {"paths": [], "bodies": []} def handler(request: httpx.Request) -> httpx.Response: captured["paths"].append((request.method, request.url.path)) @@ -542,6 +545,7 @@ def handler(request: httpx.Request) -> httpx.Response: } return httpx.Response(200, json=payload) if request.method == "POST": + captured["bodies"].append(json.loads(request.content.decode())) return httpx.Response(201, json=FLUX_API_KEY_JSON) if request.method == "GET": return httpx.Response(200, json=FLUX_API_KEY_JSON) @@ -557,8 +561,11 @@ def handler(request: httpx.Request) -> httpx.Response: keys = await client.list_flux_api_keys() assert keys.results[0].public_key == "flux_pub_abc" - created = await client.create_flux_api_key({"description": "Flux key"}) + created = await client.create_flux_api_key( + {"description": "Flux key", "role": "role-1"} + ) assert created.secret_key == "flux_sec_xyz" + assert captured["bodies"][0] == {"description": "Flux key", "role": "role-1"} detail = await client.get_flux_api_key("flux-key-1") assert detail.key == "flux-key-1" @@ -1014,14 +1021,14 @@ def handler(request: httpx.Request) -> httpx.Response: client = build_async_management_client(handler) perms = await client.list_management_role_permissions("role-1") - assert perms[0].content_type == "resources" + assert perms[0].content_type == "collection-items" created = await client.upsert_management_role_permission( "role-1", ROLE_PERMISSION_JSON ) - assert created.actions == ["read", "update"] + assert created.actions == ["read"] - await client.delete_management_role_permission("role-1", "resources") + await client.delete_management_role_permission("role-1", "collection-items") replaced = await client.replace_management_role_permissions( "role-1", [ROLE_PERMISSION_JSON] @@ -1029,14 +1036,14 @@ def handler(request: httpx.Request) -> httpx.Response: assert replaced[0].all_objects is True objects = await client.list_management_permission_objects( - "role-1", content_type="folder-items" + "role-1", content_type="collection-items" ) assert objects[0].object_key == "folder-1" added = await client.add_management_permission_object( "role-1", PERMISSION_OBJECT_JSON ) - assert added.content_type == "folder-items" + assert added.content_type == "collection-items" assert added.object_key == "folder-1" await client.delete_management_permission_object("role-1", PERMISSION_OBJECT_JSON) @@ -1119,25 +1126,6 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() -@pytest.mark.asyncio -async def test_async_create_resource_with_component(): - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["body"] = json.loads(request.content.decode()) - return httpx.Response(201, json=RESOURCE_JSON) - - client = build_async_management_client(handler) - result = await client.create_resource( - "folder-1", {"data": {"title": "Hello"}}, component="comp-1" - ) - assert result.key == "resource-1" - assert "component=comp-1" in captured["url"] - assert captured["body"]["data"]["title"] == "Hello" - await client.aclose() - - @pytest.mark.asyncio async def test_async_create_resource_with_external_id(): captured: dict[str, Any] = {} @@ -1214,31 +1202,6 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() -@pytest.mark.asyncio -async def test_async_upsert_resource_with_component(): - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["method"] = request.method - resource_json = {**RESOURCE_JSON, "external_id": "ext-2", "component": "comp-1"} - return httpx.Response(201, json=resource_json) - - client = build_async_management_client(handler) - result = await client.upsert_resource( - "folder-1", - {"data": {"title": "New"}}, - external_id="ext-2", - component="comp-1", - ) - assert captured["method"] == "PUT" - assert "external_id=ext-2" in captured["url"] - assert "component=comp-1" in captured["url"] - assert result.external_id == "ext-2" - assert result.component == "comp-1" - await client.aclose() - - # --------------------------------------------------------------------------- # async batch_upsert_resources # --------------------------------------------------------------------------- @@ -1380,28 +1343,6 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() -@pytest.mark.asyncio -async def test_async_batch_upsert_resources_with_component(): - captured: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(str(request.url)) - ext_id = str(request.url).split("external_id=")[1].split("&")[0] - return httpx.Response(200, json={**RESOURCE_JSON, "external_id": ext_id}) - - client = build_async_management_client(handler) - items = [ - BatchUpsertItem( - external_id="ext-1", payload={"title": "Item"}, component="comp-1" - ) - ] - result = await client.batch_upsert_resources("folder-1", items) - assert result.success_count == 1 - assert "component=comp-1" in captured[0] - assert "external_id=ext-1" in captured[0] - await client.aclose() - - @pytest.mark.asyncio async def test_async_batch_upsert_resources_rejects_zero_concurrency(): def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/test_clients.py b/tests/test_clients.py index cce56c4..58611ef 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -33,6 +33,7 @@ FolderSummary, ResourceSummary, RevisionSummary, + RolePermission, ) ORG_KEY = "org-1" @@ -93,7 +94,6 @@ "created_at": "2024-01-10T00:00:00Z", "vectors_size": 0, "name": None, - "component": None, "resource_owner": None, "current_revision": "rev-1", "external_id": None, @@ -286,13 +286,13 @@ } ROLE_PERMISSION_JSON = { - "content_type": "resources", - "actions": ["read", "update"], + "content_type": "collection-items", + "actions": ["read"], "all_objects": True, } PERMISSION_OBJECT_JSON = { - "content_type": "folder-items", + "content_type": "collection-items", "object_key": "folder-1", } @@ -599,8 +599,11 @@ def handler(request: httpx.Request) -> httpx.Response: keys = client.list_management_api_keys() assert keys.results[0].public_key == "manage_pub_abc" - created = client.create_management_api_key({"description": "Ops key"}) + created = client.create_management_api_key( + {"description": "Ops key", "role": "role-1"} + ) assert created.secret_key == "manage_sec_xyz" + assert captured["bodies"][0] == {"description": "Ops key", "role": "role-1"} detail = client.get_management_api_key("api-key-1") assert detail.key == "api-key-1" @@ -644,8 +647,9 @@ def handler(request: httpx.Request) -> httpx.Response: keys = client.list_flux_api_keys() assert keys.results[0].public_key == "flux_pub_abc" - created = client.create_flux_api_key({"description": "Flux key"}) + created = client.create_flux_api_key({"description": "Flux key", "role": "role-1"}) assert created.secret_key == "flux_sec_xyz" + assert captured["bodies"][0] == {"description": "Flux key", "role": "role-1"} detail = client.get_flux_api_key("flux-key-1") assert detail.key == "flux-key-1" @@ -749,12 +753,12 @@ def handler(request: httpx.Request) -> httpx.Response: client = build_management_client(handler) permissions = client.list_management_role_permissions("role-1") - assert permissions[0].content_type == "resources" + assert permissions[0].content_type == "collection-items" created = client.upsert_management_role_permission("role-1", ROLE_PERMISSION_JSON) - assert created.actions == ["read", "update"] + assert created.actions == ["read"] - client.delete_management_role_permission("role-1", "resources") + client.delete_management_role_permission("role-1", "collection-items") replaced = client.replace_management_role_permissions( "role-1", [ROLE_PERMISSION_JSON] @@ -762,24 +766,78 @@ def handler(request: httpx.Request) -> httpx.Response: assert replaced[0].all_objects is True objects = client.list_management_permission_objects( - "role-1", content_type="folder-items" + "role-1", content_type="collection-items" ) 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.content_type == "collection-items" assert added.object_key == "folder-1" client.delete_management_permission_object("role-1", PERMISSION_OBJECT_JSON) assert any("/permissions/batch/" in path for _, path in recorded) assert any( - body.get("content_type") == "folder-items" + body.get("content_type") == "collection-items" for body in bodies if isinstance(body, dict) ) +def test_upsert_management_role_permission_serializes_wire_shape(): + """The request body is forwarded verbatim: object-based grants carry + ``all_objects``, non-object-based grants omit it entirely.""" + bodies: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content.decode()) + bodies.append(body) + return httpx.Response(201, json=body) + + client = build_management_client(handler) + + structure = client.upsert_management_role_permission( + "role-1", {"content_type": "collection-structure", "actions": ["read"]} + ) + items = client.upsert_management_role_permission( + "role-1", + {"content_type": "collection-items", "actions": ["read"], "all_objects": True}, + ) + + assert bodies[0] == {"content_type": "collection-structure", "actions": ["read"]} + assert "all_objects" not in bodies[0] + assert bodies[1] == { + "content_type": "collection-items", + "actions": ["read"], + "all_objects": True, + } + + # Non-object-based permissions come back with all_objects unset. + assert structure.all_objects is None + assert items.all_objects is True + + +def test_role_permission_accepts_null_all_objects(): + """Non-object-based content types return ``all_objects: null``; the model + must parse that instead of requiring a boolean.""" + assert ( + RolePermission.model_validate( + {"content_type": "collection-structure", "actions": ["read"]} + ).all_objects + is None + ) + assert ( + RolePermission.model_validate( + { + "content_type": "collection-structure", + "actions": ["read"], + "all_objects": None, + } + ).all_objects + is None + ) + + def test_flux_role_crud_and_permissions(): captured: list[str] = [] bodies: list[Any] = [] @@ -963,23 +1021,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert captured["path"] == "/v1/env123/folders/folder-1/resources/" -def test_create_resource_supports_component_param(): - captured = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["body"] = json.loads(request.content.decode()) - return httpx.Response(201, json=RESOURCE_JSON) - - client = build_management_client(handler) - result = client.create_resource( - "folder-1", {"data": {"title": "Hello"}}, component="comp-1" - ) - assert result.key == "resource-1" - assert "component=comp-1" in captured["url"] - assert captured["body"]["data"]["title"] == "Hello" - - def test_create_resource_with_external_id(): captured = {} @@ -1001,27 +1042,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert "external_id=" not in captured["url"] -def test_create_resource_with_component_and_external_id(): - captured = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["body"] = json.loads(request.content.decode()) - resource_json = {**RESOURCE_JSON, "external_id": "ext-1", "component": "comp-1"} - return httpx.Response(201, json=resource_json) - - client = build_management_client(handler) - result = client.create_resource( - "folder-1", - {"data": {"title": "Hello"}}, - component="comp-1", - external_id="ext-1", - ) - assert result.key == "resource-1" - assert "component=comp-1" in captured["url"] - assert captured["body"]["external_id"] == "ext-1" - - def test_upsert_resource_sends_put_with_external_id(): captured = {} @@ -1045,29 +1065,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert result.external_id == "my-ext-id" -def test_upsert_resource_with_component(): - captured = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["method"] = request.method - resource_json = {**RESOURCE_JSON, "external_id": "ext-2", "component": "comp-1"} - return httpx.Response(201, json=resource_json) - - client = build_management_client(handler) - result = client.upsert_resource( - "folder-1", - {"data": {"title": "New"}}, - external_id="ext-2", - component="comp-1", - ) - assert captured["method"] == "PUT" - assert "external_id=ext-2" in captured["url"] - assert "component=comp-1" in captured["url"] - assert result.external_id == "ext-2" - assert result.component == "comp-1" - - def test_create_resource_without_external_id_omits_field_from_body(): captured = {} @@ -1311,26 +1308,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert completed_values == [1, 2, 3] -def test_batch_upsert_resources_with_component(): - captured: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured.append(str(request.url)) - ext_id = str(request.url).split("external_id=")[1].split("&")[0] - return httpx.Response(200, json={**RESOURCE_JSON, "external_id": ext_id}) - - client = build_management_client(handler) - items = [ - BatchUpsertItem( - external_id="ext-1", payload={"title": "Item"}, component="comp-1" - ) - ] - result = client.batch_upsert_resources("folder-1", items) - assert result.success_count == 1 - assert "component=comp-1" in captured[0] - assert "external_id=ext-1" in captured[0] - - def test_batch_upsert_resources_rejects_zero_concurrency(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=RESOURCE_JSON) diff --git a/tests/test_collection_methods_wire.py b/tests/test_collection_methods_wire.py new file mode 100644 index 0000000..69eb31f --- /dev/null +++ b/tests/test_collection_methods_wire.py @@ -0,0 +1,403 @@ +"""Wire-behaviour coverage for the Collection/Folder method surface (sync). + +Complements test_collections_methods.py by exercising every request-building +method that the smoke tests do not touch: api-collection get/update, schema +version CRUD, schema field get/update/delete, and the deprecated /folders/ +aliases for all of the above. Each test asserts the HTTP method, path and +(where relevant) body/query the SDK puts on the wire. +""" + +from __future__ import annotations + +import json +import warnings +from typing import Callable + +import httpx +import pytest + +from foxnose_sdk import _deprecation +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig +from foxnose_sdk.http import HttpTransport +from foxnose_sdk.management.client import ManagementClient + + +ENV_KEY = "env123" + +COLLECTION_JSON = { + "key": "coll-1", + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + "strict_reference": False, + "created_at": "2026-01-10T00:00:00Z", + "parent": None, +} + +API_COLLECTION_JSON = { + "folder": "coll-1", + "api": "my-api", + "allowed_methods": ["get_one"], + "description_get_one": None, + "description_get_many": None, + "description_search": None, + "description_schema": None, + "created_at": "2026-01-10T00:00:00Z", +} + +VERSION_JSON = { + "key": "v1", + "name": "v1", + "description": None, + "version_number": 1, + "created_at": "2026-01-10T00:00:00Z", + "published_at": None, + "archived_at": None, +} + +FIELD_JSON = { + "key": "field-1", + "name": "title", + "description": None, + "path": "title", + "parent": None, + "type": "string", + "meta": {}, + "required": False, + "nullable": True, + "multiple": False, + "localizable": False, + "searchable": False, + "private": False, +} + +EMPTY_LIST = {"count": 0, "next": None, "previous": None, "results": []} + + +def build_management_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> ManagementClient: + client = ManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + sync_client=httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +def capturing(response: httpx.Response, store: dict) -> Callable: + def handler(request: httpx.Request) -> httpx.Response: + store["method"] = request.method + store["url"] = str(request.url) + store["path"] = request.url.path + if request.content: + store["body"] = json.loads(request.content.decode()) + return response + + return handler + + +@pytest.fixture(autouse=True) +def _reset_warned(): + _deprecation._warned.clear() + yield + _deprecation._warned.clear() + + +@pytest.fixture +def _silence_deprecations(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + yield + + +# ----- canonical API ↔ Collection association ----- + + +def test_add_api_collection_includes_all_descriptions(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(201, json=API_COLLECTION_JSON), cap) + ) + client.add_api_collection( + "my-api", + "coll-1", + allowed_methods=["get_one"], + description_get_one="one", + description_get_many="many", + description_search="search", + description_schema="schema", + ) + assert cap["method"] == "POST" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/" + assert cap["body"]["folder"] == "coll-1" + assert cap["body"]["description_get_one"] == "one" + assert cap["body"]["description_get_many"] == "many" + assert cap["body"]["description_search"] == "search" + assert cap["body"]["description_schema"] == "schema" + + +def test_get_api_collection_hits_subpath(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + client.get_api_collection("my-api", "coll-1") + assert cap["method"] == "GET" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" + + +def test_update_api_collection_puts_descriptions(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + client.update_api_collection( + "my-api", + "coll-1", + allowed_methods=["get_one", "get_many"], + description_get_one="one", + description_get_many="many", + description_search="search", + description_schema="schema", + ) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" + assert cap["body"]["allowed_methods"] == ["get_one", "get_many"] + assert cap["body"]["description_schema"] == "schema" + + +# ----- canonical Collection schema versions ----- + + +def test_list_collection_versions_hits_versions_base(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + client.list_collection_versions("coll-1") + assert cap["method"] == "GET" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/" + + +def test_create_collection_version_passes_copy_from(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(201, json=VERSION_JSON), cap) + ) + client.create_collection_version("coll-1", {"name": "v2"}, copy_from="v1") + assert cap["method"] == "POST" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/" + assert "copy_from=v1" in cap["url"] + assert cap["body"]["name"] == "v2" + + +def test_get_collection_version_include_schema_query(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + client.get_collection_version("coll-1", "v1", include_schema=True) + assert cap["method"] == "GET" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/" + assert "include_schema=true" in cap["url"] + + +def test_update_collection_version_puts_payload(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + client.update_collection_version("coll-1", "v1", {"name": "renamed"}) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/" + assert cap["body"]["name"] == "renamed" + + +def test_delete_collection_version_deletes(): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.delete_collection_version("coll-1", "v1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/" + + +# ----- canonical Collection schema fields ----- + + +def test_get_collection_field_query_path(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + client.get_collection_field("coll-1", "v1", "title") + assert cap["method"] == "GET" + assert ( + cap["path"] + == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/schema/tree/field/" + ) + assert "path=title" in cap["url"] + + +def test_update_collection_field_puts_payload(): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + client.update_collection_field("coll-1", "v1", "title", {"required": True}) + assert cap["method"] == "PUT" + assert "path=title" in cap["url"] + assert cap["body"]["required"] is True + + +def test_delete_collection_field_deletes_with_path(): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.delete_collection_field("coll-1", "v1", "title") + assert cap["method"] == "DELETE" + assert "path=title" in cap["url"] + + +# ----- deprecated /folders/ aliases: association ----- + + +def test_list_api_folders_hits_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + client.list_api_folders("my-api") + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/" + + +def test_get_api_folder_hits_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + client.get_api_folder("my-api", "coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/coll-1/" + + +def test_remove_api_folder_deletes_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.remove_api_folder("my-api", "coll-1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/coll-1/" + + +# ----- deprecated /folders/ aliases: collection CRUD ----- + + +def test_get_folder_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=COLLECTION_JSON), cap) + ) + client.get_folder("coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/folders/tree/folder/" + assert "key=coll-1" in cap["url"] + + +def test_update_folder_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=COLLECTION_JSON), cap) + ) + client.update_folder("coll-1", {"name": "Renamed"}) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/folders/tree/folder/" + assert cap["body"]["name"] == "Renamed" + + +def test_delete_folder_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.delete_folder("coll-1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/folders/tree/folder/" + + +# ----- deprecated /folders/ aliases: schema versions ----- + + +def test_list_folder_versions_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + client.list_folder_versions("coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/" + + +def test_get_folder_version_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + client.get_folder_version("coll-1", "v1", include_schema=False) + assert cap["path"] == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/v1/" + assert "include_schema=false" in cap["url"] + + +def test_update_folder_version_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + client.update_folder_version("coll-1", "v1", {"name": "x"}) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/v1/" + + +def test_delete_folder_version_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.delete_folder_version("coll-1", "v1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/v1/" + + +# ----- deprecated /folders/ aliases: schema fields ----- + + +def test_get_folder_field_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + client.get_folder_field("coll-1", "v1", "title") + assert ( + cap["path"] + == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/v1/schema/tree/field/" + ) + assert "path=title" in cap["url"] + + +def test_update_folder_field_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + client.update_folder_field("coll-1", "v1", "title", {"required": True}) + assert cap["method"] == "PUT" + assert "path=title" in cap["url"] + + +def test_delete_folder_field_alias(_silence_deprecations): + cap: dict = {} + client = build_management_client(capturing(httpx.Response(204), cap)) + client.delete_folder_field("coll-1", "v1", "title") + assert cap["method"] == "DELETE" + assert "path=title" in cap["url"] diff --git a/tests/test_collection_methods_wire_async.py b/tests/test_collection_methods_wire_async.py new file mode 100644 index 0000000..3783834 --- /dev/null +++ b/tests/test_collection_methods_wire_async.py @@ -0,0 +1,339 @@ +"""Async wire-behaviour coverage for the Collection/Folder method surface. + +Mirrors test_collection_methods_wire.py on AsyncManagementClient, covering the +async request-building paths the smoke tests do not touch. +""" + +from __future__ import annotations + +import json +import warnings +from typing import Callable + +import httpx +import pytest + +from foxnose_sdk import _deprecation +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig +from foxnose_sdk.http import HttpTransport +from foxnose_sdk.management.client import AsyncManagementClient + + +ENV_KEY = "env123" + +COLLECTION_JSON = { + "key": "coll-1", + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + "strict_reference": False, + "created_at": "2026-01-10T00:00:00Z", + "parent": None, +} + +API_COLLECTION_JSON = { + "folder": "coll-1", + "api": "my-api", + "allowed_methods": ["get_one"], + "description_get_one": None, + "description_get_many": None, + "description_search": None, + "description_schema": None, + "created_at": "2026-01-10T00:00:00Z", +} + +VERSION_JSON = { + "key": "v1", + "name": "v1", + "description": None, + "version_number": 1, + "created_at": "2026-01-10T00:00:00Z", + "published_at": None, + "archived_at": None, +} + +FIELD_JSON = { + "key": "field-1", + "name": "title", + "description": None, + "path": "title", + "parent": None, + "type": "string", + "meta": {}, + "required": False, + "nullable": True, + "multiple": False, + "localizable": False, + "searchable": False, + "private": False, +} + +EMPTY_LIST = {"count": 0, "next": None, "previous": None, "results": []} + + +def build_async_management_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> AsyncManagementClient: + client = AsyncManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + async_client=httpx.AsyncClient( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +def capturing(response: httpx.Response, store: dict) -> Callable: + def handler(request: httpx.Request) -> httpx.Response: + store["method"] = request.method + store["url"] = str(request.url) + store["path"] = request.url.path + if request.content: + store["body"] = json.loads(request.content.decode()) + return response + + return handler + + +@pytest.fixture(autouse=True) +def _reset_warned(): + _deprecation._warned.clear() + yield + _deprecation._warned.clear() + + +@pytest.fixture +def _silence_deprecations(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + yield + + +# ----- canonical Collection CRUD (async) ----- + + +async def test_get_collection_by_path_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=COLLECTION_JSON), cap) + ) + await client.get_collection_by_path("/nested/path") + assert cap["path"] == f"/v1/{ENV_KEY}/collections/tree/collection/" + assert "path=%2Fnested%2Fpath" in cap["url"] + + +async def test_list_collection_tree_async_children_mode(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + await client.list_collection_tree(key="coll-1", mode="children") + assert "key=coll-1" in cap["url"] + assert "mode=children" in cap["url"] + + +async def test_update_collection_async_puts_to_tree_item(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=COLLECTION_JSON), cap) + ) + await client.update_collection("coll-1", {"name": "Renamed"}) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/tree/collection/" + assert "key=coll-1" in cap["url"] + + +# ----- canonical API ↔ Collection association (async) ----- + + +async def test_add_api_collection_async_all_descriptions(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(201, json=API_COLLECTION_JSON), cap) + ) + await client.add_api_collection( + "my-api", + "coll-1", + allowed_methods=["get_one"], + description_get_one="one", + description_get_many="many", + description_search="search", + description_schema="schema", + ) + assert cap["method"] == "POST" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/" + assert cap["body"]["description_schema"] == "schema" + + +async def test_get_api_collection_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + await client.get_api_collection("my-api", "coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" + + +async def test_update_api_collection_async_descriptions(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + await client.update_api_collection( + "my-api", + "coll-1", + allowed_methods=["get_one"], + description_get_one="one", + description_get_many="many", + description_search="search", + description_schema="schema", + ) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" + + +async def test_remove_api_collection_async(): + cap: dict = {} + client = build_async_management_client(capturing(httpx.Response(204), cap)) + await client.remove_api_collection("my-api", "coll-1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" + + +# ----- canonical Collection schema versions (async) ----- + + +async def test_list_collection_versions_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + await client.list_collection_versions("coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/" + + +async def test_create_collection_version_async_copy_from(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(201, json=VERSION_JSON), cap) + ) + await client.create_collection_version("coll-1", {"name": "v2"}, copy_from="v1") + assert cap["method"] == "POST" + assert "copy_from=v1" in cap["url"] + + +async def test_get_collection_version_async_include_schema(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + await client.get_collection_version("coll-1", "v1", include_schema=True) + assert "include_schema=true" in cap["url"] + + +async def test_update_collection_version_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=VERSION_JSON), cap) + ) + await client.update_collection_version("coll-1", "v1", {"name": "renamed"}) + assert cap["method"] == "PUT" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/" + + +async def test_delete_collection_version_async(): + cap: dict = {} + client = build_async_management_client(capturing(httpx.Response(204), cap)) + await client.delete_collection_version("coll-1", "v1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/" + + +# ----- canonical Collection schema fields (async) ----- + + +async def test_create_collection_field_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(201, json=FIELD_JSON), cap) + ) + await client.create_collection_field("coll-1", "v1", {"name": "title"}) + assert cap["method"] == "POST" + assert ( + cap["path"] + == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/schema/tree/" + ) + + +async def test_get_collection_field_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + await client.get_collection_field("coll-1", "v1", "title") + assert "path=title" in cap["url"] + + +async def test_update_collection_field_async(): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=FIELD_JSON), cap) + ) + await client.update_collection_field("coll-1", "v1", "title", {"required": True}) + assert cap["method"] == "PUT" + assert "path=title" in cap["url"] + + +async def test_delete_collection_field_async(): + cap: dict = {} + client = build_async_management_client(capturing(httpx.Response(204), cap)) + await client.delete_collection_field("coll-1", "v1", "title") + assert cap["method"] == "DELETE" + assert "path=title" in cap["url"] + + +# ----- deprecated /folders/ aliases (async) ----- + + +async def test_list_api_folders_async_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + await client.list_api_folders("my-api") + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/" + + +async def test_get_api_folder_async_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=API_COLLECTION_JSON), cap) + ) + await client.get_api_folder("my-api", "coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/coll-1/" + + +async def test_remove_api_folder_async_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_async_management_client(capturing(httpx.Response(204), cap)) + await client.remove_api_folder("my-api", "coll-1") + assert cap["method"] == "DELETE" + assert cap["path"] == f"/v1/{ENV_KEY}/api/my-api/folders/coll-1/" + + +async def test_list_folder_versions_async_legacy_url(_silence_deprecations): + cap: dict = {} + client = build_async_management_client( + capturing(httpx.Response(200, json=EMPTY_LIST), cap) + ) + await client.list_folder_versions("coll-1") + assert cap["path"] == f"/v1/{ENV_KEY}/folders/coll-1/model/versions/" diff --git a/tests/test_collection_summary_shape.py b/tests/test_collection_summary_shape.py new file mode 100644 index 0000000..e46c355 --- /dev/null +++ b/tests/test_collection_summary_shape.py @@ -0,0 +1,162 @@ +"""Behavioural tests for the post-composite-removal contract. + +Covers both the model-shape guarantees (no legacy folder_type / component +fields) and the active rejection of legacy kwargs on the client surface +and on the BatchUpsertItem model. +""" + +from __future__ import annotations + +import inspect + +import pytest +from pydantic import ValidationError + +from foxnose_sdk.management.client import ( + AsyncManagementClient, + ManagementClient, +) +from foxnose_sdk.management.models import ( + BatchUpsertItem, + CollectionSummary, + FolderSummary, + ResourceSummary, +) + + +# --------------------------------------------------------------------------- +# Model shape: no legacy fields exposed +# --------------------------------------------------------------------------- + + +def test_folder_summary_no_folder_type(): + assert "folder_type" not in FolderSummary.model_fields + + +def test_collection_summary_no_folder_type(): + assert "folder_type" not in CollectionSummary.model_fields + + +def test_resource_summary_no_component(): + assert "component" not in ResourceSummary.model_fields + + +# --------------------------------------------------------------------------- +# BatchUpsertItem must REJECT legacy component kwarg, not silently drop it. +# --------------------------------------------------------------------------- + + +def test_batch_upsert_item_rejects_legacy_component(): + """BatchUpsertItem must REJECT (not silently drop) the legacy ``component`` + kwarg so downstream callers get a clear ValidationError instead of a + confusing silent no-op.""" + with pytest.raises(ValidationError): + BatchUpsertItem( + component="cmp-xyz", + external_id="ext-1", + payload={"data": {}}, + ) + + +def test_batch_upsert_item_accepts_supported_fields(): + """Sanity check the model still accepts the supported shape.""" + item = BatchUpsertItem(external_id="ext-1", payload={"data": {"title": "Hi"}}) + assert item.external_id == "ext-1" + assert item.payload == {"data": {"title": "Hi"}} + + +# --------------------------------------------------------------------------- +# Client method signatures must not accept `component=` anymore. +# --------------------------------------------------------------------------- + + +def test_create_resource_signature_no_component(): + """The sync ManagementClient.create_resource must not accept a ``component`` + kwarg. Passing it should raise TypeError at call time (Python signature + enforcement).""" + sig = inspect.signature(ManagementClient.create_resource) + assert "component" not in sig.parameters, ( + f"create_resource must not accept `component` kwarg; " + f"parameters: {list(sig.parameters)}" + ) + + +def test_upsert_resource_signature_no_component(): + sig = inspect.signature(ManagementClient.upsert_resource) + assert "component" not in sig.parameters, ( + f"upsert_resource must not accept `component` kwarg; " + f"parameters: {list(sig.parameters)}" + ) + + +def test_async_create_resource_signature_no_component(): + sig = inspect.signature(AsyncManagementClient.create_resource) + assert "component" not in sig.parameters, ( + f"AsyncManagementClient.create_resource must not accept `component` kwarg; " + f"parameters: {list(sig.parameters)}" + ) + + +def test_async_upsert_resource_signature_no_component(): + sig = inspect.signature(AsyncManagementClient.upsert_resource) + assert "component" not in sig.parameters, ( + f"AsyncManagementClient.upsert_resource must not accept `component` kwarg; " + f"parameters: {list(sig.parameters)}" + ) + + +# --------------------------------------------------------------------------- +# URL behaviour: create_resource must not put `component=` in the request URL. +# Uses the existing httpx.MockTransport pattern from tests/test_clients.py. +# --------------------------------------------------------------------------- + + +def test_create_resource_url_does_not_include_component(): + """When ManagementClient.create_resource issues its POST, the resulting + URL must NOT contain ``component=`` — even if some forwarded-config layer + or stale environment variable somehow tried to inject it.""" + import httpx + + from foxnose_sdk.auth import SimpleKeyAuth + from foxnose_sdk.config import FoxnoseConfig + from foxnose_sdk.http import HttpTransport + + captured_urls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured_urls.append(str(request.url)) + return httpx.Response( + 201, + json={ + "key": "resource-1", + "folder": "folder-1", + "content_type": "document", + "created_at": "2024-01-10T00:00:00Z", + "vectors_size": 0, + "name": None, + "resource_owner": None, + "current_revision": "rev-1", + "external_id": None, + }, + ) + + client = ManagementClient( + base_url="https://api.example.com", + environment_key="env123", + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + sync_client=httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + + client.create_resource("folder-1", {"data": {"title": "Hello"}}) + + assert captured_urls, "expected create_resource to issue an HTTP request" + assert "component=" not in captured_urls[0], ( + f"create_resource URL must not include component=; got {captured_urls[0]}" + ) diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py new file mode 100644 index 0000000..fa1e65b --- /dev/null +++ b/tests/test_collection_type_aliases.py @@ -0,0 +1,56 @@ +"""Verify Collection* type aliases point at the original Folder* types. + +Aliases are re-exported from both the ``foxnose_sdk.management`` subpackage +and the top-level ``foxnose_sdk`` package — the test covers both surfaces so +the next person who adds a new alias notices if they only wire half the path. +""" + +import foxnose_sdk +from foxnose_sdk.management import ( + APICollectionList, + APICollectionSummary, + CollectionList, + CollectionRef, + CollectionSummary, + FolderRef, +) +from foxnose_sdk.management.models import ( + APIFolderList, + APIFolderSummary, + FolderList, + FolderSummary, +) + + +def test_collection_summary_is_folder_summary(): + assert CollectionSummary is FolderSummary + + +def test_collection_list_is_folder_list(): + assert CollectionList is FolderList + + +def test_api_collection_summary_alias(): + assert APICollectionSummary is APIFolderSummary + + +def test_api_collection_list_alias(): + assert APICollectionList is APIFolderList + + +def test_collection_ref_alias(): + assert CollectionRef is FolderRef + + +def test_top_level_package_reexports_collection_types(): + """Aliases must also be importable from the top-level ``foxnose_sdk``.""" + assert foxnose_sdk.CollectionSummary is FolderSummary + assert foxnose_sdk.CollectionList is FolderList + assert foxnose_sdk.APICollectionSummary is APIFolderSummary + assert foxnose_sdk.APICollectionList is APIFolderList + assert foxnose_sdk.CollectionRef is FolderRef + + +def test_version_string_matches_pyproject(): + """Drift check between pyproject.toml and __version__.""" + assert foxnose_sdk.__version__ == "0.6.0" diff --git a/tests/test_collections_methods.py b/tests/test_collections_methods.py new file mode 100644 index 0000000..bdcbc63 --- /dev/null +++ b/tests/test_collections_methods.py @@ -0,0 +1,429 @@ +"""Smoke tests for the Collection method surface. + +Covers the canonical Collection methods (list/get/create/update/delete + +api-association + versions + fields) and the one-shot DeprecationWarning +emitted by the corresponding Folder-named aliases. + +Verifies wire-compat: canonical methods hit /v1/{env}/collections/... +while deprecated folder methods still hit /v1/{env}/folders/... +""" + +from __future__ import annotations + +import json +import warnings +from typing import Any, Callable + +import httpx +import pytest + +from foxnose_sdk import _deprecation +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig +from foxnose_sdk.http import HttpTransport +from foxnose_sdk.management import ( + APICollectionList, + APICollectionSummary, + CollectionList, + CollectionSummary, +) +from foxnose_sdk.management.client import ManagementClient + + +ENV_KEY = "env123" + + +FOLDER_JSON = { + "key": "coll-1", + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + "strict_reference": False, + "created_at": "2026-01-10T00:00:00Z", + "parent": None, +} + +API_FOLDER_JSON = { + "folder": "coll-1", + "api": "my-api", + "allowed_methods": ["get_one"], + "description_get_one": None, + "description_get_many": None, + "description_search": None, + "description_schema": None, + "created_at": "2026-01-10T00:00:00Z", +} + +VERSION_JSON = { + "key": "v1", + "name": "v1", + "description": None, + "version_number": 1, + "created_at": "2026-01-10T00:00:00Z", + "published_at": None, + "archived_at": None, +} + +FIELD_JSON = { + "key": "field-1", + "name": "title", + "description": None, + "path": "title", + "parent": None, + "type": "string", + "meta": {}, + "required": False, + "nullable": True, + "multiple": False, + "localizable": False, + "searchable": False, + "private": False, +} + + +def build_management_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> ManagementClient: + client = ManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + sync_client=httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +@pytest.fixture(autouse=True) +def _reset_warned(): + _deprecation._warned.clear() + yield + _deprecation._warned.clear() + + +# ----- canonical Collection CRUD ----- + + +def test_list_collections_hits_collections_tree(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + result = client.list_collections() + assert isinstance(result, CollectionList) + assert captured["path"] == f"/v1/{ENV_KEY}/collections/tree/" + + +def test_get_collection_passes_key_query_param(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response(200, json=FOLDER_JSON) + + client = build_management_client(handler) + coll = client.get_collection("coll-1") + assert isinstance(coll, CollectionSummary) + assert f"/v1/{ENV_KEY}/collections/tree/collection/" in captured["url"] + assert "key=coll-1" in captured["url"] + + +def test_get_collection_by_path(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response(200, json=FOLDER_JSON) + + client = build_management_client(handler) + client.get_collection_by_path("/nested/path") + assert "path=%2Fnested%2Fpath" in captured["url"] + + +def test_list_collection_tree_children_mode(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + client.list_collection_tree(key="coll-1", mode="children") + assert "key=coll-1" in captured["url"] + assert "mode=children" in captured["url"] + + +def test_create_collection_posts_to_collections_tree(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["method"] = request.method + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=FOLDER_JSON) + + client = build_management_client(handler) + coll = client.create_collection( + { + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + } + ) + assert coll.key == "coll-1" + assert captured["method"] == "POST" + assert captured["path"] == f"/v1/{ENV_KEY}/collections/tree/" + # Wire field name folder_type is preserved. + assert captured["body"]["folder_type"] == "collection" + + +def test_update_collection_puts_to_tree_item(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response(200, json=FOLDER_JSON) + + client = build_management_client(handler) + client.update_collection("coll-1", {"name": "Renamed"}) + assert captured["method"] == "PUT" + assert f"/v1/{ENV_KEY}/collections/tree/collection/" in captured["url"] + assert "key=coll-1" in captured["url"] + + +def test_delete_collection_deletes_at_tree_item(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response(204) + + client = build_management_client(handler) + client.delete_collection("coll-1") + assert captured["method"] == "DELETE" + assert f"/v1/{ENV_KEY}/collections/tree/collection/" in captured["url"] + + +# ----- canonical API ↔ Collection association ----- + + +def test_add_api_collection_posts_with_folder_wire_field(): + """Wire-compat: POST body uses the legacy `folder` field name.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON) + + client = build_management_client(handler) + res = client.add_api_collection( + "my-api", "coll-1", allowed_methods=["get_one"] + ) + assert isinstance(res, APICollectionSummary) + assert captured["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/" + assert captured["body"]["folder"] == "coll-1" + + +def test_list_api_collections_returns_list_model(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + res = client.list_api_collections("my-api") + assert isinstance(res, APICollectionList) + + +def test_remove_api_collection_hits_collection_subpath(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response(204) + + client = build_management_client(handler) + client.remove_api_collection("my-api", "coll-1") + assert captured["method"] == "DELETE" + assert f"/v1/{ENV_KEY}/api/my-api/collections/coll-1/" in captured["url"] + + +# ----- canonical Collection schema versions + fields ----- + + +def test_publish_collection_version_hits_publish_subpath(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response( + 200, json={**VERSION_JSON, "published_at": "2026-01-11T00:00:00Z"} + ) + + client = build_management_client(handler) + client.publish_collection_version("coll-1", "v1") + assert captured["method"] == "POST" + assert ( + f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/publish/" + in captured["url"] + ) + + +def test_list_collection_fields_hits_collection_schema_tree(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + client.list_collection_fields("coll-1", "v1") + assert ( + captured["path"] + == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/schema/tree/" + ) + + +def test_create_collection_field_posts_payload(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=FIELD_JSON) + + client = build_management_client(handler) + client.create_collection_field( + "coll-1", "v1", {"name": "title", "type": "string"} + ) + assert captured["body"]["name"] == "title" + + +# ----- deprecation warnings ----- + + +def test_list_folders_emits_deprecation_warning(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.list_folders() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1 + msg = str(deprecations[0].message) + assert "list_folders" in msg + assert "list_collections" in msg + + +def test_list_folders_still_hits_legacy_folders_url(): + """Deprecated aliases keep their original wire behaviour (hit /folders/).""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + client.list_folders() + assert captured["path"] == f"/v1/{ENV_KEY}/folders/tree/" + + +def test_add_api_folder_emits_deprecation_warning(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=API_FOLDER_JSON) + + client = build_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.add_api_folder("my-api", "coll-1", allowed_methods=["get_one"]) + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations + assert "add_api_folder" in str(deprecations[0].message) + + +def test_publish_folder_version_emits_deprecation_warning(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={**VERSION_JSON, "published_at": "2026-01-11T00:00:00Z"} + ) + + client = build_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.publish_folder_version("coll-1", "v1") + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations + assert "publish_folder_version" in str(deprecations[0].message) + + +def test_list_folder_fields_emits_deprecation_warning(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.list_folder_fields("coll-1", "v1") + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations + assert "list_folder_fields" in str(deprecations[0].message) + + +def test_deprecation_warning_once_per_process_per_method(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.list_folders() + client.list_folders() + client.list_folders() + deprecations = [ + w + for w in caught + if issubclass(w.category, DeprecationWarning) + and "list_folders" in str(w.message) + ] + assert len(deprecations) == 1 diff --git a/tests/test_collections_methods_async.py b/tests/test_collections_methods_async.py new file mode 100644 index 0000000..79fd280 --- /dev/null +++ b/tests/test_collections_methods_async.py @@ -0,0 +1,319 @@ +"""Async parity tests for the Collection method surface. + +Mirrors test_collections_methods.py with async invocation. Ensures every +canonical method exists and behaves on AsyncManagementClient + every legacy +folder method still emits the DeprecationWarning while hitting the legacy +/folders/ URL. +""" + +from __future__ import annotations + +import json +import warnings +from typing import Any, Callable + +import httpx +import pytest + +from foxnose_sdk import _deprecation +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig +from foxnose_sdk.http import HttpTransport +from foxnose_sdk.management import ( + APICollectionList, + APICollectionSummary, + CollectionList, + CollectionSummary, +) +from foxnose_sdk.management.client import AsyncManagementClient + + +ENV_KEY = "env123" + + +FOLDER_JSON = { + "key": "coll-1", + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + "strict_reference": False, + "created_at": "2026-01-10T00:00:00Z", + "parent": None, +} + +API_FOLDER_JSON = { + "folder": "coll-1", + "api": "my-api", + "allowed_methods": ["get_one"], + "description_get_one": None, + "description_get_many": None, + "description_search": None, + "description_schema": None, + "created_at": "2026-01-10T00:00:00Z", +} + +VERSION_JSON = { + "key": "v1", + "name": "v1", + "description": None, + "version_number": 1, + "created_at": "2026-01-10T00:00:00Z", + "published_at": None, + "archived_at": None, +} + +FIELD_JSON = { + "key": "field-1", + "name": "title", + "description": None, + "path": "title", + "parent": None, + "type": "string", + "meta": {}, + "required": False, + "nullable": True, + "multiple": False, + "localizable": False, + "searchable": False, + "private": False, +} + + +def build_async_management_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> AsyncManagementClient: + client = AsyncManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + async_client=httpx.AsyncClient( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +@pytest.fixture(autouse=True) +def _reset_warned(): + _deprecation._warned.clear() + yield + _deprecation._warned.clear() + + +# ----- canonical Collection CRUD (async) ----- + + +async def test_list_collections_async_hits_collections_tree(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["path"] = req.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_async_management_client(handler) + result = await client.list_collections() + assert isinstance(result, CollectionList) + assert captured["path"] == f"/v1/{ENV_KEY}/collections/tree/" + + +async def test_get_collection_async_passes_key(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["url"] = str(req.url) + return httpx.Response(200, json=FOLDER_JSON) + + client = build_async_management_client(handler) + res = await client.get_collection("coll-1") + assert isinstance(res, CollectionSummary) + assert f"/v1/{ENV_KEY}/collections/tree/collection/" in captured["url"] + assert "key=coll-1" in captured["url"] + + +async def test_create_collection_async_posts(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["method"] = req.method + captured["path"] = req.url.path + captured["body"] = json.loads(req.content.decode()) + return httpx.Response(201, json=FOLDER_JSON) + + client = build_async_management_client(handler) + await client.create_collection( + { + "name": "Articles", + "alias": "articles", + "folder_type": "collection", + "content_type": "document", + } + ) + assert captured["method"] == "POST" + assert captured["path"] == f"/v1/{ENV_KEY}/collections/tree/" + # Wire-compat: payload still carries folder_type wire field + assert captured["body"]["folder_type"] == "collection" + + +async def test_delete_collection_async_deletes_at_tree_item(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["url"] = str(req.url) + captured["method"] = req.method + return httpx.Response(204) + + client = build_async_management_client(handler) + await client.delete_collection("coll-1") + assert captured["method"] == "DELETE" + assert f"/v1/{ENV_KEY}/collections/tree/collection/" in captured["url"] + + +# ----- canonical API ↔ Collection (async) ----- + + +async def test_add_api_collection_async_uses_folder_wire_field(): + """Wire-compat: POST body uses the legacy 'folder' field name.""" + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["path"] = req.url.path + captured["body"] = json.loads(req.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON) + + client = build_async_management_client(handler) + res = await client.add_api_collection( + "my-api", "coll-1", allowed_methods=["get_one"] + ) + assert isinstance(res, APICollectionSummary) + assert captured["path"] == f"/v1/{ENV_KEY}/api/my-api/collections/" + assert captured["body"]["folder"] == "coll-1" + + +async def test_list_api_collections_async(): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_async_management_client(handler) + res = await client.list_api_collections("my-api") + assert isinstance(res, APICollectionList) + + +# ----- canonical Collection schema versions + fields (async) ----- + + +async def test_publish_collection_version_async(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["url"] = str(req.url) + return httpx.Response( + 200, json={**VERSION_JSON, "published_at": "2026-01-11T00:00:00Z"} + ) + + client = build_async_management_client(handler) + await client.publish_collection_version("coll-1", "v1") + assert ( + f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/publish/" + in captured["url"] + ) + + +async def test_list_collection_fields_async_hits_schema_tree(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["path"] = req.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_async_management_client(handler) + await client.list_collection_fields("coll-1", "v1") + assert ( + captured["path"] + == f"/v1/{ENV_KEY}/collections/coll-1/model/versions/v1/schema/tree/" + ) + + +# ----- deprecation warnings (async) ----- + + +async def test_list_folders_async_emits_deprecation_warning(): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_async_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.list_folders() + deprecations = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecations) == 1 + assert "list_folders" in str(deprecations[0].message) + + +async def test_list_folders_async_still_hits_legacy_url(): + """Async deprecated alias keeps its original wire behaviour (/folders/).""" + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["path"] = req.url.path + return httpx.Response( + 200, + json={"count": 0, "next": None, "previous": None, "results": []}, + ) + + client = build_async_management_client(handler) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + await client.list_folders() + assert captured["path"] == f"/v1/{ENV_KEY}/folders/tree/" + + +async def test_add_api_folder_async_emits_warning(): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=API_FOLDER_JSON) + + client = build_async_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.add_api_folder("my-api", "coll-1", allowed_methods=["get_one"]) + deprecations = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + assert deprecations + assert "add_api_folder" in str(deprecations[0].message) + + +async def test_publish_folder_version_async_emits_warning(): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={**VERSION_JSON, "published_at": "2026-01-11T00:00:00Z"} + ) + + client = build_async_management_client(handler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.publish_folder_version("coll-1", "v1") + deprecations = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + assert deprecations + assert "publish_folder_version" in str(deprecations[0].message) diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py new file mode 100644 index 0000000..7794133 --- /dev/null +++ b/tests/test_deprecation.py @@ -0,0 +1,45 @@ +"""Tests for the one-shot DeprecationWarning helper.""" + +import warnings + +import pytest + +from foxnose_sdk import _deprecation +from foxnose_sdk._deprecation import warn_deprecated_method + + +@pytest.fixture(autouse=True) +def _reset_warned(): + _deprecation._warned.clear() + yield + _deprecation._warned.clear() + + +def test_warn_emits_once_per_old_name(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warn_deprecated_method("list_folders", "list_collections") + warn_deprecated_method("list_folders", "list_collections") + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1 + msg = str(deprecations[0].message) + assert "list_folders" in msg + assert "list_collections" in msg + + +def test_warn_emits_once_per_distinct_method(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warn_deprecated_method("get_folder", "get_collection") + warn_deprecated_method("create_folder", "create_collection") + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 2 + + +def test_warn_message_mentions_removal_version(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warn_deprecated_method("foo", "bar", removal="2.0") + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1 + assert "2.0" in str(deprecations[0].message) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..d573278 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig, RetryConfig +from foxnose_sdk.errors import ( + FoxnoseAPIError, + PlanExhausted, + PlanLimitExceeded, + RateLimitExceeded, + SpendCapExceeded, + _header_lookup, +) +from foxnose_sdk.http import HttpTransport + + +def _transport( + handler, + *, + retry_config: RetryConfig | None = None, +) -> HttpTransport: + return HttpTransport( + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + retry_config=retry_config, + sync_client=httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + + +def _async_transport( + handler, + *, + retry_config: RetryConfig | None = None, +) -> HttpTransport: + return HttpTransport( + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + retry_config=retry_config, + async_client=httpx.AsyncClient( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + + +def test_402_spend_cap_reached_maps_to_spend_cap_exceeded(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={ + "error_code": "spend_cap_reached", + "cap_usd": 100.0, + "cycle_resets_at": "2026-08-01T00:00:00Z", + "raise_cap_url": "https://app.example.com/billing", + }, + ) + + transport = _transport(handler) + with pytest.raises(SpendCapExceeded) as exc: + transport.request("GET", "/v1/test") + err = exc.value + assert isinstance(err, FoxnoseAPIError) + assert err.status_code == 402 + assert err.error_code == "spend_cap_reached" + assert err.cap_usd == 100.0 + assert err.cycle_resets_at == "2026-08-01T00:00:00Z" + assert err.raise_cap_url == "https://app.example.com/billing" + # Body has no top-level message; a default is supplied. + assert err.message == "Spend cap reached" + + +def test_402_spend_cap_null_cap_usd(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={ + "error_code": "spend_cap_reached", + "cap_usd": None, + "cycle_resets_at": "2026-08-01T00:00:00Z", + "raise_cap_url": "https://app.example.com/billing", + }, + ) + + transport = _transport(handler) + with pytest.raises(SpendCapExceeded) as exc: + transport.request("GET", "/v1/test") + assert exc.value.cap_usd is None + + +def test_402_plan_exhausted_maps_to_plan_exhausted(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={ + "error_code": "plan_exhausted", + "axis": "retrievals", + "window_resets_at": "2026-08-01T00:00:00Z", + "upgrade_url": "https://app.example.com/upgrade", + }, + ) + + transport = _transport(handler) + with pytest.raises(PlanExhausted) as exc: + transport.request("GET", "/v1/test") + err = exc.value + assert isinstance(err, FoxnoseAPIError) + assert err.axis == "retrievals" + assert err.window_resets_at == "2026-08-01T00:00:00Z" + assert err.upgrade_url == "https://app.example.com/upgrade" + assert err.message == "Plan allowance exhausted" + + +def test_402_is_not_retried(): + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response( + 402, + json={ + "error_code": "plan_exhausted", + "axis": "writes", + "window_resets_at": "2026-08-01T00:00:00Z", + "upgrade_url": "https://app.example.com/upgrade", + }, + ) + + transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=0)) + with pytest.raises(PlanExhausted): + transport.request("GET", "/v1/test") + assert attempts["count"] == 1 + + +def test_403_plan_limit_exceeded_with_upgrade_url(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 403, + json={ + "message": "Plan limit exceeded", + "error_code": "plan_limit_exceeded", + "detail": { + "entity": "collections", + "current": 10, + "limit": 10, + "upgrade_url": "https://app.example.com/upgrade", + }, + }, + ) + + transport = _transport(handler) + with pytest.raises(PlanLimitExceeded) as exc: + transport.request("GET", "/v1/test") + err = exc.value + assert isinstance(err, FoxnoseAPIError) + assert err.entity == "collections" + assert err.current == 10 + assert err.limit == 10 + assert err.upgrade_url == "https://app.example.com/upgrade" + assert err.message == "Plan limit exceeded" + + +def test_403_plan_limit_exceeded_without_upgrade_url(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 403, + json={ + "message": "Plan limit exceeded", + "error_code": "plan_limit_exceeded", + "detail": { + "entity": "projects", + "current": 3, + "limit": 3, + }, + }, + ) + + transport = _transport(handler) + with pytest.raises(PlanLimitExceeded) as exc: + transport.request("GET", "/v1/test") + err = exc.value + assert err.entity == "projects" + assert err.current == 3 + assert err.limit == 3 + assert err.upgrade_url is None + + +def test_429_rate_limited_on_post_not_retried(): + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response( + 429, + json={"error_code": "rate_limited", "message": "Rate limit exceeded"}, + headers={"Retry-After": "42"}, + ) + + transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=0)) + with pytest.raises(RateLimitExceeded) as exc: + transport.request("POST", "/v1/test", json_body={"data": "x"}) + err = exc.value + assert isinstance(err, FoxnoseAPIError) + # POST is not a retryable method: raised on the first attempt. + assert attempts["count"] == 1 + # Retry-After parsed case-insensitively from a real httpx response header. + assert err.retry_after == 42.0 + + +def test_429_rate_limited_on_get_retried_then_raises(): + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response( + 429, + json={"error_code": "rate_limited", "message": "Rate limit exceeded"}, + headers={"Retry-After": "0"}, + ) + + transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=0)) + with pytest.raises(RateLimitExceeded): + transport.request("GET", "/v1/test") + assert attempts["count"] == 3 + + +def test_429_rate_limited_malformed_retry_after(): + """A non-numeric Retry-After header parses to retry_after=None, not an error.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error_code": "rate_limited", "message": "Rate limit exceeded"}, + headers={"Retry-After": "not-a-number"}, + ) + + transport = _transport(handler, retry_config=RetryConfig(attempts=1, backoff_factor=0)) + with pytest.raises(RateLimitExceeded) as exc: + transport.request("POST", "/v1/test", json_body={"data": "x"}) + assert exc.value.retry_after is None + + +def test_header_lookup_edge_cases(): + # No headers at all. + assert _header_lookup(None, "Retry-After") is None + assert _header_lookup({}, "Retry-After") is None + # Present headers but the target is absent. + assert _header_lookup({"Content-Type": "application/json"}, "Retry-After") is None + # Case-insensitive match. + assert _header_lookup({"retry-after": "5"}, "Retry-After") == "5" + + +def test_429_unknown_code_stays_generic(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error_code": "insufficient_units", "message": "No units left"}, + ) + + transport = _transport(handler, retry_config=RetryConfig(attempts=1, backoff_factor=0)) + with pytest.raises(FoxnoseAPIError) as exc: + transport.request("GET", "/v1/test") + assert type(exc.value) is FoxnoseAPIError + assert exc.value.error_code == "insufficient_units" + + +def test_402_unknown_code_stays_generic(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={"error_code": "something_new", "message": "Payment issue"}, + ) + + transport = _transport(handler) + with pytest.raises(FoxnoseAPIError) as exc: + transport.request("GET", "/v1/test") + assert type(exc.value) is FoxnoseAPIError + + +@pytest.mark.parametrize("body", [b"null", b"[]", b'"a bare string"']) +def test_malformed_body_on_mapped_status_falls_through(body: bytes): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + content=body, + headers={"Content-Type": "application/json"}, + ) + + transport = _transport(handler) + with pytest.raises(FoxnoseAPIError) as exc: + transport.request("GET", "/v1/test") + # error_code cannot be read from a non-object body, so it stays generic. + assert type(exc.value) is FoxnoseAPIError + assert exc.value.status_code == 402 + + +def test_base_except_catches_each_subclass(): + cases: list[tuple[int, dict[str, Any], dict[str, str] | None]] = [ + ( + 402, + {"error_code": "spend_cap_reached", "cap_usd": 5.0}, + None, + ), + ( + 402, + {"error_code": "plan_exhausted", "axis": "writes"}, + None, + ), + ( + 403, + { + "message": "Plan limit exceeded", + "error_code": "plan_limit_exceeded", + "detail": {"entity": "roles", "current": 1, "limit": 1}, + }, + None, + ), + ( + 429, + {"error_code": "rate_limited", "message": "Rate limit exceeded"}, + {"Retry-After": "1"}, + ), + ] + for status_code, json_body, headers in cases: + def handler(request: httpx.Request, _json=json_body, _status=status_code, _headers=headers) -> httpx.Response: + return httpx.Response(_status, json=_json, headers=_headers) + + transport = _transport(handler, retry_config=RetryConfig(attempts=1, backoff_factor=0)) + with pytest.raises(FoxnoseAPIError): + transport.request("GET", "/v1/test") + + +@pytest.mark.asyncio +async def test_async_402_spend_cap_reached(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, + json={ + "error_code": "spend_cap_reached", + "cap_usd": 250.0, + "cycle_resets_at": "2026-09-01T00:00:00Z", + "raise_cap_url": "https://app.example.com/billing", + }, + ) + + transport = _async_transport(handler) + with pytest.raises(SpendCapExceeded) as exc: + await transport.arequest("GET", "/v1/test") + assert exc.value.cap_usd == 250.0 + + +@pytest.mark.asyncio +async def test_async_429_rate_limited_retry_after(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error_code": "rate_limited", "message": "Rate limit exceeded"}, + headers={"Retry-After": "7"}, + ) + + transport = _async_transport( + handler, retry_config=RetryConfig(attempts=1, backoff_factor=0) + ) + with pytest.raises(RateLimitExceeded) as exc: + await transport.arequest("POST", "/v1/test", json_body={"x": 1}) + assert exc.value.retry_after == 7.0 diff --git a/tests/test_sync_collection_component.py b/tests/test_sync_collection_component.py new file mode 100644 index 0000000..1d29295 --- /dev/null +++ b/tests/test_sync_collection_component.py @@ -0,0 +1,296 @@ +"""Tests for sync_collection_component + NestedFieldMeta. + +Mirrors the TS SDK's coverage: +- routing/body-shape for ManagementClient.sync_collection_component +- AsyncManagementClient.sync_collection_component +- NestedFieldMeta helper (required fields, defaults, .to_meta()) +- client-side invariant: to_versions keys must be a subset of field_paths +""" + +from __future__ import annotations + +import json +from typing import Any, Callable + +import httpx +import pytest +from pydantic import ValidationError + +from foxnose_sdk.auth import SimpleKeyAuth +from foxnose_sdk.config import FoxnoseConfig +from foxnose_sdk.http import HttpTransport +from foxnose_sdk.management import ( + AsyncManagementClient, + ManagementClient, + NestedFieldMeta, + SyncComponentResponse, +) + + +ENV_KEY = "env123" + +SYNC_OK_JSON: dict[str, Any] = { + "synced_paths": ["seo"], + "skipped": [{"path": "hero", "reason": "auto_update_mode"}], + "schema_version": "ver-new-12345", +} + + +def _build_client(handler: Callable[[httpx.Request], httpx.Response]) -> ManagementClient: + client = ManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + sync_client=httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +def _build_async_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> AsyncManagementClient: + client = AsyncManagementClient( + base_url="https://api.example.com", + environment_key=ENV_KEY, + auth=SimpleKeyAuth("pub", "secret"), + ) + client._transport = HttpTransport( # type: ignore[attr-defined] + config=FoxnoseConfig(base_url="https://api.example.com"), + auth=SimpleKeyAuth("pub", "secret"), + async_client=httpx.AsyncClient( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + ), + ) + return client + + +# ---------------------------------------------------------------------- +# sync_collection_component — sync client +# ---------------------------------------------------------------------- + + +def test_sync_collection_component_empty_body_hits_endpoint(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["method"] = request.method + captured["body"] = json.loads(request.content.decode() or "{}") + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_client(handler) + result = client.sync_collection_component("articles") + + assert captured["method"] == "POST" + assert captured["path"] == f"/v1/{ENV_KEY}/collections/articles/sync_component/" + assert captured["body"] == {} + assert isinstance(result, SyncComponentResponse) + assert result.synced_paths == ["seo"] + assert result.skipped[0].path == "hero" + assert result.skipped[0].reason == "auto_update_mode" + assert result.schema_version == "ver-new-12345" + + +def test_sync_collection_component_field_paths_only(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode() or "{}") + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_client(handler) + client.sync_collection_component("articles", field_paths=["seo"]) + assert captured["body"] == {"field_paths": ["seo"]} + + +def test_sync_collection_component_field_paths_and_to_versions(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode() or "{}") + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_client(handler) + client.sync_collection_component( + "articles", + field_paths=["seo", "hero"], + to_versions={"seo": "ver-target-abc"}, + ) + assert captured["body"] == { + "field_paths": ["seo", "hero"], + "to_versions": {"seo": "ver-target-abc"}, + } + + +def test_sync_collection_component_rejects_to_versions_extras_subset(): + """Client-side invariant: to_versions keys must be a subset of field_paths.""" + # No HTTP call expected — handler should never run. + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("HTTP request must not be issued") + + client = _build_client(handler) + with pytest.raises(ValueError) as exc: + client.sync_collection_component( + "articles", + field_paths=["seo"], + to_versions={"hero": "ver-something"}, + ) + assert "hero" in str(exc.value) + + +def test_sync_collection_component_accepts_to_versions_alone(): + """to_versions without field_paths is fine — server treats it as 'sync only these'.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode() or "{}") + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_client(handler) + client.sync_collection_component( + "articles", + to_versions={"seo": "ver-target-abc"}, + ) + assert captured["body"] == {"to_versions": {"seo": "ver-target-abc"}} + + +def test_sync_collection_component_accepts_collection_summary_ref(): + """The collection_key argument can also be a model object with a `key` attribute.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_client(handler) + + class _CollectionRef: + key = "articles" + + client.sync_collection_component(_CollectionRef()) # type: ignore[arg-type] + assert captured["path"] == f"/v1/{ENV_KEY}/collections/articles/sync_component/" + + +# ---------------------------------------------------------------------- +# sync_collection_component — async client +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_sync_collection_component_hits_same_endpoint(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode() or "{}") + return httpx.Response(200, json=SYNC_OK_JSON) + + client = _build_async_client(handler) + result = await client.sync_collection_component( + "articles", field_paths=["seo"], to_versions={"seo": "ver-target-abc"} + ) + assert captured["path"] == f"/v1/{ENV_KEY}/collections/articles/sync_component/" + assert captured["body"] == { + "field_paths": ["seo"], + "to_versions": {"seo": "ver-target-abc"}, + } + assert isinstance(result, SyncComponentResponse) + + +@pytest.mark.asyncio +async def test_async_sync_collection_component_rejects_to_versions_extras_subset(): + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("HTTP request must not be issued") + + client = _build_async_client(handler) + with pytest.raises(ValueError): + await client.sync_collection_component( + "articles", + field_paths=["seo"], + to_versions={"hero": "ver-something"}, + ) + + +# ---------------------------------------------------------------------- +# NestedFieldMeta helper +# ---------------------------------------------------------------------- + + +def test_nested_field_meta_required_fields(): + """component AND component_version are required; auto_update defaults to False.""" + with pytest.raises(ValidationError): + NestedFieldMeta(component="cmp-abc123") # type: ignore[call-arg] + with pytest.raises(ValidationError): + NestedFieldMeta(component_version="ver-abc123") # type: ignore[call-arg] + + +def test_nested_field_meta_defaults_auto_update_false(): + meta = NestedFieldMeta(component="cmp-abc123", component_version="ver-def456") + assert meta.auto_update is False + assert meta.to_meta() == { + "component": "cmp-abc123", + "component_version": "ver-def456", + "auto_update": False, + } + + +def test_nested_field_meta_explicit_auto_update_true(): + meta = NestedFieldMeta( + component="cmp-abc123", component_version="ver-def456", auto_update=True + ) + assert meta.to_meta()["auto_update"] is True + + +def test_nested_field_meta_short_uid_rejected(): + """component_version too short → ValidationError (min_length=6).""" + with pytest.raises(ValidationError): + NestedFieldMeta(component="cmp-abc123", component_version="x") + + +def test_nested_field_meta_accepts_extra_keys(): + """extra=allow → callers can attach title/description/etc.""" + meta = NestedFieldMeta( + component="cmp-abc123", + component_version="ver-def456", + title="Hero block", # type: ignore[call-arg] + ) + assert meta.to_meta()["title"] == "Hero block" + + +# ---------------------------------------------------------------------- +# SyncComponentResponse parsing +# ---------------------------------------------------------------------- + + +def test_sync_component_response_parses_minimal_payload(): + r = SyncComponentResponse.model_validate( + {"synced_paths": [], "skipped": [], "schema_version": None} + ) + assert r.synced_paths == [] + assert r.skipped == [] + assert r.schema_version is None + + +def test_sync_component_response_skipped_items_typed(): + r = SyncComponentResponse.model_validate( + { + "synced_paths": ["seo"], + "skipped": [ + {"path": "hero", "reason": "auto_update_mode"}, + {"path": "footer", "reason": "already_at_target"}, + ], + "schema_version": "ver-new-12345", + } + ) + assert [s.reason for s in r.skipped] == [ + "auto_update_mode", + "already_at_target", + ]