From 0ef9f988c22e3de895ad4623e49e19a78dc7dd35 Mon Sep 17 00:00:00 2001 From: loookashow Date: Sat, 8 Aug 2026 19:23:38 +0200 Subject: [PATCH 1/5] feat: truncate_text query params and cross-parent connection fields; release 0.8.0 - FluxClient/AsyncFluxClient.search() accept a keyword-only `params` mapping, forwarded to the query string, so `truncate_text` (and any other query param) works on Search as it already did on list_resources(). - vector_search/vector_field_search/hybrid_search/boosted_search (sync and async) gain a `query_params` keyword forwarded the same way. Named `query_params` rather than `params` because `params` was already a meaningful **extra_body key (forwarded to the JSON body); an explicit `params` keyword would have silently rerouted an existing caller's body field to the query string. `_merge_extra` now raises if `truncate_text` is passed as a body field, naming `query_params` as the fix. - APIFolderSummary (APICollectionSummary) gains unscoped_levels, unscoped_ancestors, expose_owner, flat_route (new FlatRouteSummary model, undocumented) and flat_routes (new FlatRoute model per entry) to type the cross-parent (flat) read addressing already reachable via Flux's opaque folder-path strings. APIFolderSummary and both new models set extra="allow" so unrecognized fields are preserved, not dropped. - add_api_collection/update_api_collection and the deprecated add_api_folder/update_api_folder aliases (sync and async) accept unscoped_levels/unscoped_ancestors to configure the addresses. - Version bumped to 0.8.0 (additive). Co-Authored-By: Claude Opus 5 --- docs/changelog.md | 71 ++++- docs/flux-client.md | 103 ++++++- docs/management-client.md | 52 ++++ src/foxnose_sdk/__init__.py | 4 + src/foxnose_sdk/_version.py | 2 +- src/foxnose_sdk/flux/client.py | 57 +++- src/foxnose_sdk/management/client.py | 67 ++++- src/foxnose_sdk/management/models.py | 59 ++++ tests/test_async_clients.py | 408 ++++++++++++++++++++++++++ tests/test_clients.py | 403 +++++++++++++++++++++++++ tests/test_collection_type_aliases.py | 2 +- 11 files changed, 1211 insertions(+), 17 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 705c9df..644d7e9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-08-08 + +### Added + +- **`truncate_text` query parameter on Flux search.** `FluxClient.search()` / + `AsyncFluxClient.search()` now accept a keyword-only `params` mapping, + forwarded to the query string (the transport already supported this; it + was not plumbed through `search()`). Combined with the existing + passthrough on `list_resources()`, both List Resources and Search now + support `truncate_text` — an integer ≥ 1 that caps every `text`-typed + field in the response and is ignored when `raw=true`. The response marks + truncated fields under `_sys.truncated` (each entry has `field`, `locale` + — `null` for non-localized fields — and `original_length`); fields within + the limit get no entry. There is no client-side validation: an invalid + value (non-integer or < 1) surfaces as a server `422 validation_error`. +- **`query_params` on the four search wrappers** — `vector_search()`, + `vector_field_search()`, `hybrid_search()`, `boosted_search()` (sync and + async) — forwarded to the query string, so `truncate_text` (or any other + query parameter) can be combined with the typed search helpers. Named + `query_params` rather than `params` deliberately: `params` was already a + meaningful key inside `**extra_body` (forwarded to the JSON body), and an + explicit `params` keyword would have silently rerouted it to the query + string for any existing caller passing `params={...}` as a search-body + field. `_merge_extra` now raises if handed `truncate_text` as a body + field, naming `query_params` as the correct place for it. +- **Cross-parent (flat) read addressing** for strict-reference collections. + `add_api_collection` / `update_api_collection` (and the deprecated + `add_api_folder` / `update_api_folder` aliases), sync and async, accept + `unscoped_levels: list[int]` and `unscoped_ancestors: list[str]` to expose + additional read-only addresses that drop (fully-flat, `level == 0`) or + partially drop (`level >= 1`, retaining the root-most ancestor keys) the + ancestor chain of a nested collection path. The API requires the two to be + sent together — not enforced client-side, since the server owns that rule + and may relax it. + - `APIFolderSummary` (aliased `APICollectionSummary`) gains + `unscoped_levels`, `unscoped_ancestors`, `expose_owner` (all default to + an empty/false value if the server omits them) and `flat_route` / + `flat_routes` (both optional *and* nullable — observed `null` on + connections with no key-bearing ancestor). New `FlatRoute` model for + each entry of `flat_routes`; new `FlatRouteSummary` model for the + singular `flat_route`, which is undocumented and mirrors whichever + route is currently enabled — no behavior is built on it. + - `APIFolderSummary` and both new route models now set + `model_config = ConfigDict(extra="allow")`, so unrecognized fields + (present or future) are preserved instead of silently dropped. + - List, Get Resource, and Schema already worked against these addresses + (the collection path is an opaque, slash-trimmed string); this release + only adds typed support for the connection object and for configuring + the addresses. + - Cross-parent writes are out of scope: `FluxClient` has no create/update + method that takes a folder path other than the ones already documented, + and the server rejects writes on flat paths regardless. + - Not addressed by this release, and not planned to be worked around in + the SDK: `flat_routes[].read_methods` reports `["get_one", "get_many"]` + at level 0 even though the docs describe Get Resource as available only + at `level >= 1`, and `read_methods` never includes `search` at any + level despite the docs listing Search as available on flat addresses. + Both are raised with the API team; `read_methods` is typed as sent. + +### Fixed + +- The default `DEFAULT_USER_AGENT` was pinned to `foxnose-sdk/0.1.0` + regardless of the installed version. The version now lives in a single + `_version.py` module imported by both `foxnose_sdk/__init__.py` (so + `foxnose_sdk.__version__` keeps working) and `config.py` (so the + User-Agent reports the real version), avoiding the circular import that + motivated the stale constant in the first place. + ## [0.7.1] - 2026-07-24 ### Added @@ -182,7 +250,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.7.1...HEAD +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...v0.8.0 [0.7.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 diff --git a/docs/flux-client.md b/docs/flux-client.md index ff0146d..34a81aa 100644 --- a/docs/flux-client.md +++ b/docs/flux-client.md @@ -89,6 +89,10 @@ for item in results["results"]: print(item["data"]["title"]) ``` +`search()` also accepts a keyword-only `params` mapping, forwarded to the +query string (not the body) — e.g. `params={"truncate_text": 200}`. See +[Truncating Text Fields](#truncating-text-fields) below. + ## Writing Resources Writes require a write-capable key. Creating and updating publish immediately; @@ -212,17 +216,110 @@ resources = client.list_resources( ### Pagination +`next` in the response is a full absolute URL (e.g. +`https://.fxns.io//posts?limit=10&next=Xk6KmhmHZuAU`), not a bare +cursor. The SDK does not follow it automatically — `list_resources` always +targets the configured `base_url`/`api_prefix`, so a server-returned absolute +URL can't be fed back in directly. Extract the `next` query parameter and +re-pass it (along with any other parameters you want to keep, such as +`truncate_text`) instead: + ```python +from urllib.parse import urlparse, parse_qs + # First page -page1 = client.list_resources("posts", params={"limit": 10}) +page1 = client.list_resources("posts", params={"limit": 10, "truncate_text": 200}) -# Next page (use the cursor from the previous response) +# Next page: pull the cursor out of the absolute `next` URL and re-pass it +# alongside the parameters you want to carry forward. if page1["next"]: - page2 = client.list_resources("posts", params={"limit": 10, "next": ""}) + cursor = parse_qs(urlparse(page1["next"]).query)["next"][0] + page2 = client.list_resources( + "posts", + params={"limit": 10, "truncate_text": 200, "next": cursor}, + ) print(f"Got {len(page1['results'])} items") ``` +If you ever build a helper that follows `next` automatically, validate that +the URL is same-origin with your configured `base_url` *before* attaching any +auth header — blindly following a server-supplied absolute URL with +credentials attached is an SSRF and credential-leak vector. + +### Truncating Text Fields + +Pass `truncate_text` (an integer ≥ 1) on List Resources or Search to cap +every `text`-typed field in the response. It has no effect when `raw=true`. + +```python +resources = client.list_resources("blog-posts", params={"truncate_text": 200}) +results = client.search( + "blog-posts", + body={"find_text": {"query": "python"}}, + params={"truncate_text": 200}, +) +``` + +Truncated fields are marked under `_sys.truncated`: + +```json +{ + "_sys": { + "key": "Cpa3KebZoqb3", + "truncated": [{"field": "body", "locale": null, "original_length": 210}] + } +} +``` + +`locale` is `null` for non-localized fields; fields within the limit get no +entry. The SDK does not validate `truncate_text` client-side — there is no +typed request model for query parameters to hang a validator on. An invalid +value (non-integer, or < 1) surfaces as a server `422 validation_error`. + +The same parameter works with the typed search wrappers — `vector_search()`, +`vector_field_search()`, `hybrid_search()`, `boosted_search()` — via +`query_params`, e.g. `client.vector_search("blog-posts", query="ml", +query_params={"truncate_text": 200})`. Use `query_params`, not `params`: the +wrappers already collect unrecognized keyword arguments (like `where` and +`sort`) into the JSON body, and `params` is a plausible body field name. +Passing `truncate_text` as a body field raises a clear error naming +`query_params` as the fix. + +## Cross-Parent Addressing + +A strict-reference collection nested under one or more parents can also be +configured (via the Management API, see +[API Folder Route Descriptions](management-client.md#api-folder-route-descriptions)) +to expose additional, read-only addresses that drop some or all of the +ancestor keys from the path: + +```python +# Normal, fully-nested address: +client.list_resources("realty/accounts/acc_1/listings/lst_1/photos") + +# Fully-flat (level 0): every ancestor key is dropped. +client.list_resources("realty/accounts/listings/photos") + +# Partially-flat (level >= 1): the root-most ancestor key(s) are retained. +client.list_resources("realty/accounts/acc_1/listings/photos") +``` + +This works today for List Resources, Get Resource, and Schema — the folder +path is an opaque, slash-trimmed string with no segment parsing or ancestor +validation on the client side. **Writes cannot target a flat address**: +`FluxClient` has no method that would let you, and the server rejects writes +on flat paths regardless. + +Not every configured level necessarily serves every read method. Before +relying on a flat address, check `enabled`, `available`, and `read_methods` +on the connection's `flat_routes` (see +[API Folder Route Descriptions](management-client.md#api-folder-route-descriptions)) +rather than assuming every read method is available at every level — the +docs and the live API disagree on this in at least two ways that are still +open with the API team (Get Resource at level 0; Search never appears in +`read_methods` at any level). + ## Error Handling ```python diff --git a/docs/management-client.md b/docs/management-client.md index 837566a..bfdd355 100644 --- a/docs/management-client.md +++ b/docs/management-client.md @@ -133,6 +133,58 @@ updated = client.update_api_folder( ) ``` +### Cross-Parent (Flat) Read Addressing + +For a strict-reference collection nested under one or more parents, pass +`unscoped_levels` and `unscoped_ancestors` to expose additional, read-only +addresses that drop some or all of the ancestor keys from the Flux path: + +```python +connection = client.add_api_collection( + api_key="api-key", + collection_key="photos", + unscoped_levels=[0], # 0 = fully flat; 1+ retains that many root ancestors + unscoped_ancestors=[ + "01debe0d-0325-42b1-9bfd-ef52046cd785", # accounts connection (UUID) + "432880c9-ae43-4462-b4f3-f16c18068ea5", # listings connection (UUID) + ], +) +``` + +The two must be sent together — the API rejects a level set with an empty +ancestor chain. This is not validated client-side; the server owns the rule +and may relax it. + +`unscoped_ancestors` and the `omitted_ancestors` / `retained_ancestors` +entries below are connection UUIDs, **not** the short collection keys used +elsewhere (e.g. `folder`). Do not conflate the two. + +The connection object returned by `add_api_collection`, `update_api_collection`, +`get_api_collection`, and `list_api_collections` carries these fields, +**read-only** — configure the addresses via `unscoped_levels` / +`unscoped_ancestors` above, not by constructing `flat_routes` yourself: + +- `unscoped_levels: list[int]`, `unscoped_ancestors: list[str]` — echo what + was configured. +- `expose_owner: bool` — present on the wire; semantics are unconfirmed, so + the SDK does not expose a way to set it. +- `flat_routes: list[FlatRoute] | None` — one entry per configured level, + each with `level`, `path`, `omitted_ancestors`, `retained_ancestors`, + `enabled`, `read_methods`, `available`, `unavailable_reason`, + `published_generation`, and `router_generation`. `None` when the + connection has no key-bearing ancestor to flatten (not `[]`). +- `flat_route: FlatRouteSummary | None` — a single, undocumented field that + appears to mirror whichever `flat_routes` entry is enabled. No behavior is + built on it in the SDK. + +Before relying on a flat address, check `enabled`, `available`, and +`read_methods` per entry — `read_methods` is typed exactly as the server +sends it, including two known discrepancies from the docs (raised with the +API team, not worked around here): Get Resource appears in `read_methods` at +level 0 even though the docs say it needs `level >= 1`, and `search` never +appears in `read_methods` at any level even though the docs list Search as +available on flat addresses. + ## Folder Operations ### List Folders diff --git a/src/foxnose_sdk/__init__.py b/src/foxnose_sdk/__init__.py index 4b99115..f690743 100644 --- a/src/foxnose_sdk/__init__.py +++ b/src/foxnose_sdk/__init__.py @@ -70,6 +70,8 @@ EnvironmentSummary, FieldList, FieldSummary, + FlatRoute, + FlatRouteSummary, FolderList, FolderSummary, FluxAPIKeyList, @@ -157,6 +159,8 @@ "SchemaVersionList", "FieldSummary", "FieldList", + "FlatRoute", + "FlatRouteSummary", "ProjectSummary", "ProjectList", "EnvironmentSummary", diff --git a/src/foxnose_sdk/_version.py b/src/foxnose_sdk/_version.py index 18fcd79..b7c7fe2 100644 --- a/src/foxnose_sdk/_version.py +++ b/src/foxnose_sdk/_version.py @@ -4,4 +4,4 @@ by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``). """ -__version__ = "0.7.1" +__version__ = "0.8.0" diff --git a/src/foxnose_sdk/flux/client.py b/src/foxnose_sdk/flux/client.py index 65c2ecd..6048310 100644 --- a/src/foxnose_sdk/flux/client.py +++ b/src/foxnose_sdk/flux/client.py @@ -20,6 +20,11 @@ def _merge_extra(validated: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]: """Merge extra_body into the validated payload, rejecting key conflicts.""" + if "truncate_text" in extra: + raise ValueError( + "'truncate_text' is a query parameter, not a body field. " + "Pass it via 'query_params={\"truncate_text\": ...}' instead." + ) conflicts = _SEARCH_REQUEST_FIELDS & extra.keys() if conflicts: raise ValueError( @@ -101,9 +106,20 @@ def search( folder_path: str, *, body: Mapping[str, Any], + params: Mapping[str, Any] | None = None, ) -> Any: + """Search a collection. + + Args: + folder_path: Collection path to search. + body: The search request body (see :class:`SearchRequest`). + params: Optional query parameters, e.g. ``{"truncate_text": 200}`` + to cap every ``text``-typed field in the response. Values + are not validated client-side; an invalid ``truncate_text`` + (non-integer or < 1) surfaces as a server ``422``. + """ path = self._build_path(folder_path, suffix="/_search") - return self._transport.request("POST", path, json_body=body) + return self._transport.request("POST", path, json_body=body, params=params) def create_resource( self, @@ -179,6 +195,7 @@ def vector_search( similarity_threshold: float | None = None, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Semantic search using auto-generated embeddings.""" @@ -194,7 +211,7 @@ def vector_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return self.search(folder_path, body=body) + return self.search(folder_path, body=body, params=query_params) def vector_field_search( self, @@ -206,6 +223,7 @@ def vector_field_search( similarity_threshold: float | None = None, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Search using custom pre-computed embeddings.""" @@ -221,7 +239,7 @@ def vector_field_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return self.search(folder_path, body=body) + return self.search(folder_path, body=body, params=query_params) def hybrid_search( self, @@ -237,6 +255,7 @@ def hybrid_search( rerank_results: bool = True, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Blended text + vector search with configurable weights.""" @@ -258,7 +277,7 @@ def hybrid_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return self.search(folder_path, body=body) + return self.search(folder_path, body=body, params=query_params) def boosted_search( self, @@ -275,6 +294,7 @@ def boosted_search( max_boost_results: int = 20, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Text search with results boosted by vector similarity.""" @@ -319,7 +339,7 @@ def boosted_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return self.search(folder_path, body=body) + return self.search(folder_path, body=body, params=query_params) def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any: """Return available routes and contracts under the configured API prefix.""" @@ -399,9 +419,22 @@ async def search( folder_path: str, *, body: Mapping[str, Any], + params: Mapping[str, Any] | None = None, ) -> Any: + """Search a collection. + + Args: + folder_path: Collection path to search. + body: The search request body (see :class:`SearchRequest`). + params: Optional query parameters, e.g. ``{"truncate_text": 200}`` + to cap every ``text``-typed field in the response. Values + are not validated client-side; an invalid ``truncate_text`` + (non-integer or < 1) surfaces as a server ``422``. + """ path = self._build_path(folder_path, suffix="/_search") - return await self._transport.arequest("POST", path, json_body=body) + return await self._transport.arequest( + "POST", path, json_body=body, params=params + ) async def create_resource( self, @@ -477,6 +510,7 @@ async def vector_search( similarity_threshold: float | None = None, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Semantic search using auto-generated embeddings.""" @@ -492,7 +526,7 @@ async def vector_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return await self.search(folder_path, body=body) + return await self.search(folder_path, body=body, params=query_params) async def vector_field_search( self, @@ -504,6 +538,7 @@ async def vector_field_search( similarity_threshold: float | None = None, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Search using custom pre-computed embeddings.""" @@ -519,7 +554,7 @@ async def vector_field_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return await self.search(folder_path, body=body) + return await self.search(folder_path, body=body, params=query_params) async def hybrid_search( self, @@ -535,6 +570,7 @@ async def hybrid_search( rerank_results: bool = True, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Blended text + vector search with configurable weights.""" @@ -556,7 +592,7 @@ async def hybrid_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return await self.search(folder_path, body=body) + return await self.search(folder_path, body=body, params=query_params) async def boosted_search( self, @@ -573,6 +609,7 @@ async def boosted_search( max_boost_results: int = 20, limit: int | None = None, offset: int | None = None, + query_params: Mapping[str, Any] | None = None, **extra_body: Any, ) -> Any: """Text search with results boosted by vector similarity.""" @@ -617,7 +654,7 @@ async def boosted_search( offset=offset, ) body = _merge_extra(req.model_dump(exclude_none=True), extra_body) - return await self.search(folder_path, body=body) + return await self.search(folder_path, body=body, params=query_params) async def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any: """Return available routes and contracts under the configured API prefix.""" diff --git a/src/foxnose_sdk/management/client.py b/src/foxnose_sdk/management/client.py index 71168ac..403a531 100644 --- a/src/foxnose_sdk/management/client.py +++ b/src/foxnose_sdk/management/client.py @@ -628,6 +628,8 @@ def add_api_collection( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APICollectionSummary: """Add a collection to an API. @@ -639,6 +641,13 @@ def add_api_collection( 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. + unscoped_levels: Ancestor-nesting levels at which to expose + cross-parent (flat) read addresses for a strict-reference + collection, e.g. ``[0]`` for a fully-flat address. + unscoped_ancestors: UUIDs of the ancestor connections the flat + addresses apply to. The API requires this to be sent together + with ``unscoped_levels`` — a level set with an empty ancestor + chain is rejected server-side; this is not enforced here. Note: The POST body uses the wire field name ``folder`` for compatibility. @@ -656,6 +665,10 @@ def add_api_collection( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = self.request( "POST", f"{self._api_collections_root(api_key)}/", json_body=payload ) @@ -682,8 +695,20 @@ def update_api_collection( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APICollectionSummary: - """Update a collection's configuration within an API.""" + """Update a collection's configuration within an API. + + Args: + unscoped_levels: Ancestor-nesting levels at which to expose + cross-parent (flat) read addresses for a strict-reference + collection, e.g. ``[0]`` for a fully-flat address. + unscoped_ancestors: UUIDs of the ancestor connections the flat + addresses apply to. The API requires this to be sent together + with ``unscoped_levels`` — a level set with an empty ancestor + chain is rejected server-side; this is not enforced here. + """ api_key = _resolve_key(api_key) collection_key = _resolve_key(collection_key) payload: dict[str, Any] = {} @@ -697,6 +722,10 @@ def update_api_collection( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = self.request( "PUT", f"{self._api_collections_root(api_key)}/{collection_key}/", @@ -739,6 +768,8 @@ def add_api_folder( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APIFolderSummary: """Deprecated alias for :meth:`add_api_collection`.""" warn_deprecated_method("add_api_folder", "add_api_collection") @@ -755,6 +786,10 @@ def add_api_folder( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = self.request( "POST", f"{self._api_folders_root(api_key)}/", json_body=payload ) @@ -780,6 +815,8 @@ def update_api_folder( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APIFolderSummary: """Deprecated alias for :meth:`update_api_collection`.""" warn_deprecated_method("update_api_folder", "update_api_collection") @@ -796,6 +833,10 @@ def update_api_folder( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = self.request( "PUT", f"{self._api_folders_root(api_key)}/{folder_key}/", json_body=payload ) @@ -3017,6 +3058,8 @@ async def add_api_collection( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APICollectionSummary: api_key = _resolve_key(api_key) collection_key = _resolve_key(collection_key) @@ -3031,6 +3074,10 @@ async def add_api_collection( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = await self.request( "POST", f"{self._api_collections_root(api_key)}/", json_body=payload ) @@ -3056,6 +3103,8 @@ async def update_api_collection( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APICollectionSummary: api_key = _resolve_key(api_key) collection_key = _resolve_key(collection_key) @@ -3070,6 +3119,10 @@ async def update_api_collection( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = await self.request( "PUT", f"{self._api_collections_root(api_key)}/{collection_key}/", @@ -3112,6 +3165,8 @@ async def add_api_folder( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APIFolderSummary: warn_deprecated_method("add_api_folder", "add_api_collection") api_key = _resolve_key(api_key) @@ -3127,6 +3182,10 @@ async def add_api_folder( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = await self.request( "POST", f"{self._api_folders_root(api_key)}/", json_body=payload ) @@ -3153,6 +3212,8 @@ async def update_api_folder( description_get_many: str | None = None, description_search: str | None = None, description_schema: str | None = None, + unscoped_levels: list[int] | None = None, + unscoped_ancestors: list[str] | None = None, ) -> APIFolderSummary: warn_deprecated_method("update_api_folder", "update_api_collection") api_key = _resolve_key(api_key) @@ -3168,6 +3229,10 @@ async def update_api_folder( payload["description_search"] = description_search if description_schema is not None: payload["description_schema"] = description_schema + if unscoped_levels is not None: + payload["unscoped_levels"] = unscoped_levels + if unscoped_ancestors is not None: + payload["unscoped_ancestors"] = unscoped_ancestors data = await self.request( "PUT", f"{self._api_folders_root(api_key)}/{folder_key}/", json_body=payload ) diff --git a/src/foxnose_sdk/management/models.py b/src/foxnose_sdk/management/models.py index 5799d60..66d1f80 100644 --- a/src/foxnose_sdk/management/models.py +++ b/src/foxnose_sdk/management/models.py @@ -394,9 +394,53 @@ class APIInfo(BaseModel): APIList = PaginatedResponse[APIInfo] +class FlatRoute(BaseModel): + """One cross-parent (flat) read address for a strict-reference collection. + + Fully-flat (``level == 0``) drops every ancestor key from the path; + partially-flat (``level >= 1``) retains the root-most ancestor keys. + """ + + model_config = ConfigDict(extra="allow") + + level: int + path: str + omitted_ancestors: list[str] + retained_ancestors: list[str] + enabled: bool + read_methods: list[str] + available: bool + unavailable_reason: str | None + published_generation: int + router_generation: int + + +class FlatRouteSummary(BaseModel): + """The connection's currently-active flat route, singular. + + Undocumented: present on connection responses but not covered by the API + docs. It appears to mirror whichever entry of ``flat_routes`` is enabled, + minus ``level`` and ``retained_ancestors``. No behavior is built on it; + it is typed here only so it is not silently dropped. + """ + + model_config = ConfigDict(extra="allow") + + path: str + omitted_ancestors: list[str] + enabled: bool + read_methods: list[str] + available: bool + unavailable_reason: str | None + published_generation: int + router_generation: int + + class APIFolderSummary(BaseModel): """Association between an API and a folder.""" + model_config = ConfigDict(extra="allow") + folder: str api: str | None = None path: str | None = None @@ -407,6 +451,19 @@ class APIFolderSummary(BaseModel): description_schema: str | None = None created_at: datetime | None = None + # Cross-parent (flat) addressing. `unscoped_levels`/`unscoped_ancestors` + # and `expose_owner` were present on every connection inspected + # (including ones with no flat routes, as `[]`, `[]`, `False`) — not + # nullable, but defaulted so an omitted key degrades to an empty value + # instead of a parse error. `flat_route`/`flat_routes` were observed + # `null` on a connection with no key-bearing ancestor, so both are + # optional *and* nullable. + unscoped_levels: list[int] = Field(default_factory=list) + unscoped_ancestors: list[str] = Field(default_factory=list) + expose_owner: bool = False + flat_route: FlatRouteSummary | None = None + flat_routes: list[FlatRoute] | None = None + APIFolderList = PaginatedResponse[APIFolderSummary] @@ -630,4 +687,6 @@ def has_failures(self) -> bool: "BatchUpsertItem", "BatchItemError", "BatchUpsertResult", + "FlatRoute", + "FlatRouteSummary", ] diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index a087755..bc82a23 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -13,6 +13,7 @@ from foxnose_sdk.management.client import AsyncManagementClient from foxnose_sdk.errors import FoxnoseAPIError, UpstreamError from foxnose_sdk.management.models import ( + APIFolderSummary, BatchUpsertItem, BatchUpsertResult, FolderSummary, @@ -1692,6 +1693,102 @@ def handler(request: httpx.Request) -> httpx.Response: await client.aclose() +@pytest.mark.asyncio +async def test_async_add_api_collection_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_async_management_client(handler) + await client.add_api_collection( + "api-1", + "folder-1", + unscoped_levels=[0], + unscoped_ancestors=["anc-1"], + ) + assert captured["body"]["unscoped_levels"] == [0] + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + + await client.add_api_collection("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_update_api_collection_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(200, json=API_FOLDER_JSON | captured["body"]) + + client = build_async_management_client(handler) + await client.update_api_collection( + "api-1", + "folder-1", + unscoped_levels=[0, 1], + unscoped_ancestors=["anc-1", "anc-2"], + ) + assert captured["body"]["unscoped_levels"] == [0, 1] + assert captured["body"]["unscoped_ancestors"] == ["anc-1", "anc-2"] + + await client.update_api_collection("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_add_api_folder_deprecated_alias_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_async_management_client(handler) + await client.add_api_folder( + "api-1", + "folder-1", + unscoped_levels=[0], + unscoped_ancestors=["anc-1"], + ) + assert captured["body"]["unscoped_levels"] == [0] + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + + await client.add_api_folder("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_update_api_folder_deprecated_alias_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(200, json=API_FOLDER_JSON | captured["body"]) + + client = build_async_management_client(handler) + await client.update_api_folder( + "api-1", + "folder-1", + unscoped_levels=[0, 1], + unscoped_ancestors=["anc-1", "anc-2"], + ) + assert captured["body"]["unscoped_levels"] == [0, 1] + assert captured["body"]["unscoped_ancestors"] == ["anc-1", "anc-2"] + + await client.update_api_folder("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + await client.aclose() + + async def test_async_create_api_passes_agent_and_cors_fields_and_parses_response(): captured: dict[str, Any] = {} @@ -2170,3 +2267,314 @@ async def test_async_flux_boosted_search_requires_embedding(): find_text={"query": "keyword"}, ) await flux.aclose() + + +# --------------------------------------------------------------------------- +# `search()` query params (truncate_text) and `query_params` on the four +# search wrappers -- async mirror of tests/test_clients.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_flux_search_sends_truncate_text_in_query_string_not_body(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + assert "truncate_text" not in captured["body"] + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_flux_vector_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.vector_search( + "articles", + query="hello", + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_flux_vector_field_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.vector_field_search( + "articles", + field="emb", + query_vector=[0.1, 0.2], + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_flux_hybrid_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.hybrid_search( + "articles", + query="hello", + find_text={"query": "hello"}, + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_flux_boosted_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.boosted_search( + "articles", + find_text={"query": "keyword"}, + query="hello", + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_merge_extra_rejects_truncate_text_and_names_query_params(): + flux = _build_async_flux_client(lambda r: httpx.Response(200, json=SEARCH_RESPONSE)) + with pytest.raises(ValueError, match="query_params"): + await flux.vector_search("articles", query="hello", truncate_text=50) + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_flux_vector_search_params_extra_body_still_lands_in_body(): + """Regression pin: a caller already passing `params={...}` as a body extra + must keep landing in the JSON body unchanged; `query_params` must not + reroute it to the query string. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + await flux.vector_search( + "articles", + query="hello", + params={"some": "value"}, + ) + assert captured["body"]["params"] == {"some": "value"} + assert captured["query"] == {} + await flux.aclose() + + +# --------------------------------------------------------------------------- +# Cross-parent (flat) address path-construction pins -- async mirror. +# +# These prove the SDK builds the URL for a fully-flat and a partially-flat +# collection path correctly. They do NOT prove the server serves a given +# read method at that level. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_flux_builds_fully_flat_path_for_list_get_search_schema(): + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.url.path) + if request.url.path.endswith("/_schema"): + return httpx.Response( + 200, + json={ + "json_schema": {"type": "object"}, + "searchable_fields": [], + "non_searchable_fields": [], + "path": "/v1/realty/accounts/listings/photos", + "actions": [], + }, + ) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + flat_path = "realty/accounts/listings/photos" + await flux.list_resources(flat_path) + await flux.get_resource(flat_path, "photo-1") + await flux.search(flat_path, body={"find_text": {"query": "x"}}) + await flux.get_schema(flat_path) + await flux.aclose() + assert captured == [ + "/v1/realty/accounts/listings/photos", + "/v1/realty/accounts/listings/photos/photo-1", + "/v1/realty/accounts/listings/photos/_search", + "/v1/realty/accounts/listings/photos/_schema", + ] + + +@pytest.mark.asyncio +async def test_async_flux_builds_partially_flat_path_for_list_get_search_schema(): + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.url.path) + if request.url.path.endswith("/_schema"): + return httpx.Response( + 200, + json={ + "json_schema": {"type": "object"}, + "searchable_fields": [], + "non_searchable_fields": [], + "path": "/v1/realty/accounts/{accounts_key}/listings/photos", + "actions": [], + }, + ) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + partial_path = "realty/accounts/acc-1/listings/photos" + await flux.list_resources(partial_path) + await flux.get_resource(partial_path, "photo-1") + await flux.search(partial_path, body={"find_text": {"query": "x"}}) + await flux.get_schema(partial_path) + await flux.aclose() + assert captured == [ + "/v1/realty/accounts/acc-1/listings/photos", + "/v1/realty/accounts/acc-1/listings/photos/photo-1", + "/v1/realty/accounts/acc-1/listings/photos/_search", + "/v1/realty/accounts/acc-1/listings/photos/_schema", + ] + + +# --------------------------------------------------------------------------- +# APIFolderSummary shape -- async mirror (the model is shared, but the plan +# requires both test files to cover it). +# --------------------------------------------------------------------------- + +PRODUCTION_CONNECTION_JSON = { + "folder": "9wjjtw76dyj0", + "api": "949sr5xz7kcj", + "created_at": "2026-08-01T06:43:11.331505-05:00", + "allowed_methods": ["get_one", "get_many"], + "description_get_one": "Returns one resource by id.", + "description_get_many": "Returns a paginated list of resources.", + "description_search": "Searches resources by filters.", + "description_schema": "Returns JSON schema for this resource.", + "unscoped_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "unscoped_levels": [0], + "expose_owner": False, + "flat_route": { + "path": "/realty/accounts/listings/photos", + "omitted_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "enabled": True, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + "flat_routes": [ + { + "level": 0, + "path": "/realty/accounts/listings/photos", + "omitted_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "retained_ancestors": [], + "enabled": True, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + { + "level": 1, + "path": "/realty/accounts/{accounts_key}/listings/photos", + "omitted_ancestors": ["432880c9-ae43-4462-b4f3-f16c18068ea5"], + "retained_ancestors": ["01debe0d-0325-42b1-9bfd-ef52046cd785"], + "enabled": False, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + ], +} + + +def test_api_folder_summary_parses_full_production_connection_async_file(): + summary = APIFolderSummary.model_validate(PRODUCTION_CONNECTION_JSON) + assert summary.unscoped_levels == [0] + assert summary.flat_route.path == "/realty/accounts/listings/photos" + assert len(summary.flat_routes) == 2 + assert summary.flat_routes[1].retained_ancestors == [ + "01debe0d-0325-42b1-9bfd-ef52046cd785" + ] + + +def test_api_folder_summary_parses_null_flat_route_and_flat_routes_async_file(): + payload = {**API_FOLDER_JSON, "flat_route": None, "flat_routes": None} + summary = APIFolderSummary.model_validate(payload) + assert summary.flat_route is None + assert summary.flat_routes is None + assert summary.unscoped_levels == [] + assert summary.unscoped_ancestors == [] + assert summary.expose_owner is False + + +def test_api_folder_summary_preserves_unknown_fields_via_extra_allow_async_file(): + payload = { + **PRODUCTION_CONNECTION_JSON, + "some_future_top_level_flag": True, + "flat_routes": [ + { + **PRODUCTION_CONNECTION_JSON["flat_routes"][0], + "some_future_route_flag": "x", + } + ], + } + summary = APIFolderSummary.model_validate(payload) + assert summary.model_extra["some_future_top_level_flag"] is True + assert summary.flat_routes[0].model_extra["some_future_route_flag"] == "x" diff --git a/tests/test_clients.py b/tests/test_clients.py index 457783b..9f34198 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -32,6 +32,7 @@ UpstreamError, ) from foxnose_sdk.management.models import ( + APIFolderSummary, BatchItemError, BatchUpsertItem, BatchUpsertResult, @@ -2379,3 +2380,405 @@ def handler(request: httpx.Request) -> httpx.Response: flux = _build_flux_client(handler) flux.search("articles", body={"find_text": {"query": "old style"}, "limit": 5}) assert captured["body"] == {"find_text": {"query": "old style"}, "limit": 5} + + +# --------------------------------------------------------------------------- +# `search()` query params (truncate_text) and `query_params` on the four +# search wrappers. +# --------------------------------------------------------------------------- + + +def test_flux_search_sends_truncate_text_in_query_string_not_body(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + assert "truncate_text" not in captured["body"] + + +def test_flux_vector_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.vector_search( + "articles", + query="hello", + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + + +def test_flux_vector_field_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.vector_field_search( + "articles", + field="emb", + query_vector=[0.1, 0.2], + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + + +def test_flux_hybrid_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.hybrid_search( + "articles", + query="hello", + find_text={"query": "hello"}, + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + + +def test_flux_boosted_search_forwards_query_params_to_query_string(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.boosted_search( + "articles", + find_text={"query": "keyword"}, + query="hello", + query_params={"truncate_text": 50}, + ) + assert captured["query"] == {"truncate_text": "50"} + + +def test_merge_extra_rejects_truncate_text_and_names_query_params(): + flux = _build_flux_client(lambda r: httpx.Response(200, json=SEARCH_RESPONSE)) + with pytest.raises(ValueError, match="query_params"): + flux.vector_search("articles", query="hello", truncate_text=50) + + +def test_flux_vector_search_params_extra_body_still_lands_in_body(): + """Regression pin: a caller already passing `params={...}` as a body extra + (collected via **extra_body, since `vector_search` has no `params` + keyword) must keep landing in the JSON body unchanged. `query_params` is + additive and must not reroute a pre-existing `params` extra to the query + string. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flux.vector_search( + "articles", + query="hello", + params={"some": "value"}, + ) + assert captured["body"]["params"] == {"some": "value"} + assert captured["query"] == {} + + +# --------------------------------------------------------------------------- +# Cross-parent (flat) address path-construction pins. +# +# These prove the SDK builds the URL for a fully-flat and a partially-flat +# collection path correctly. They do NOT prove the server serves a given +# read method at that level -- that is a server-side contract (see the two +# `read_methods` discrepancies raised with the API team). +# --------------------------------------------------------------------------- + + +def test_flux_builds_fully_flat_path_for_list_get_search_schema(): + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.url.path) + if request.url.path.endswith("/_schema"): + return httpx.Response( + 200, + json={ + "json_schema": {"type": "object"}, + "searchable_fields": [], + "non_searchable_fields": [], + "path": "/v1/realty/accounts/listings/photos", + "actions": [], + }, + ) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + flat_path = "realty/accounts/listings/photos" + flux.list_resources(flat_path) + flux.get_resource(flat_path, "photo-1") + flux.search(flat_path, body={"find_text": {"query": "x"}}) + flux.get_schema(flat_path) + assert captured == [ + "/v1/realty/accounts/listings/photos", + "/v1/realty/accounts/listings/photos/photo-1", + "/v1/realty/accounts/listings/photos/_search", + "/v1/realty/accounts/listings/photos/_schema", + ] + + +def test_flux_builds_partially_flat_path_for_list_get_search_schema(): + captured: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.url.path) + if request.url.path.endswith("/_schema"): + return httpx.Response( + 200, + json={ + "json_schema": {"type": "object"}, + "searchable_fields": [], + "non_searchable_fields": [], + "path": "/v1/realty/accounts/{accounts_key}/listings/photos", + "actions": [], + }, + ) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + partial_path = "realty/accounts/acc-1/listings/photos" + flux.list_resources(partial_path) + flux.get_resource(partial_path, "photo-1") + flux.search(partial_path, body={"find_text": {"query": "x"}}) + flux.get_schema(partial_path) + assert captured == [ + "/v1/realty/accounts/acc-1/listings/photos", + "/v1/realty/accounts/acc-1/listings/photos/photo-1", + "/v1/realty/accounts/acc-1/listings/photos/_search", + "/v1/realty/accounts/acc-1/listings/photos/_schema", + ] + + +# --------------------------------------------------------------------------- +# APIFolderSummary / cross-parent connection shape. +# --------------------------------------------------------------------------- + +PRODUCTION_CONNECTION_JSON = { + "folder": "9wjjtw76dyj0", + "api": "949sr5xz7kcj", + "created_at": "2026-08-01T06:43:11.331505-05:00", + "allowed_methods": ["get_one", "get_many"], + "description_get_one": "Returns one resource by id.", + "description_get_many": "Returns a paginated list of resources.", + "description_search": "Searches resources by filters.", + "description_schema": "Returns JSON schema for this resource.", + "unscoped_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "unscoped_levels": [0], + "expose_owner": False, + "flat_route": { + "path": "/realty/accounts/listings/photos", + "omitted_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "enabled": True, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + "flat_routes": [ + { + "level": 0, + "path": "/realty/accounts/listings/photos", + "omitted_ancestors": [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ], + "retained_ancestors": [], + "enabled": True, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + { + "level": 1, + "path": "/realty/accounts/{accounts_key}/listings/photos", + "omitted_ancestors": ["432880c9-ae43-4462-b4f3-f16c18068ea5"], + "retained_ancestors": ["01debe0d-0325-42b1-9bfd-ef52046cd785"], + "enabled": False, + "read_methods": ["get_one", "get_many"], + "available": True, + "unavailable_reason": None, + "published_generation": 18, + "router_generation": 18, + }, + ], +} + + +def test_api_folder_summary_parses_full_production_connection(): + summary = APIFolderSummary.model_validate(PRODUCTION_CONNECTION_JSON) + assert summary.unscoped_levels == [0] + assert summary.unscoped_ancestors == [ + "01debe0d-0325-42b1-9bfd-ef52046cd785", + "432880c9-ae43-4462-b4f3-f16c18068ea5", + ] + assert summary.expose_owner is False + assert summary.flat_route is not None + assert summary.flat_route.path == "/realty/accounts/listings/photos" + assert summary.flat_routes is not None + assert len(summary.flat_routes) == 2 + assert summary.flat_routes[0].level == 0 + assert summary.flat_routes[0].retained_ancestors == [] + assert summary.flat_routes[1].level == 1 + assert summary.flat_routes[1].retained_ancestors == [ + "01debe0d-0325-42b1-9bfd-ef52046cd785" + ] + + +def test_api_folder_summary_parses_null_flat_route_and_flat_routes(): + payload = {**API_FOLDER_JSON, "flat_route": None, "flat_routes": None} + summary = APIFolderSummary.model_validate(payload) + assert summary.flat_route is None + assert summary.flat_routes is None + # Genuinely absent (not present in API_FOLDER_JSON at all) -> defaults. + assert summary.unscoped_levels == [] + assert summary.unscoped_ancestors == [] + assert summary.expose_owner is False + + +def test_api_folder_summary_preserves_unknown_fields_via_extra_allow(): + payload = { + **PRODUCTION_CONNECTION_JSON, + "some_future_top_level_flag": True, + "flat_routes": [ + { + **PRODUCTION_CONNECTION_JSON["flat_routes"][0], + "some_future_route_flag": "x", + } + ], + } + summary = APIFolderSummary.model_validate(payload) + assert summary.model_extra is not None + assert summary.model_extra["some_future_top_level_flag"] is True + assert summary.flat_routes[0].model_extra["some_future_route_flag"] == "x" + + +# --------------------------------------------------------------------------- +# add_api_collection / update_api_collection (+ deprecated add_api_folder / +# update_api_folder aliases) send unscoped_levels / unscoped_ancestors when +# given, and omit them when not. +# --------------------------------------------------------------------------- + + +def test_add_api_collection_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_management_client(handler) + client.add_api_collection( + "api-1", + "folder-1", + unscoped_levels=[0], + unscoped_ancestors=["anc-1"], + ) + assert captured["body"]["unscoped_levels"] == [0] + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + + client.add_api_collection("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + + +def test_update_api_collection_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(200, json=API_FOLDER_JSON | captured["body"]) + + client = build_management_client(handler) + client.update_api_collection( + "api-1", + "folder-1", + unscoped_levels=[0, 1], + unscoped_ancestors=["anc-1", "anc-2"], + ) + assert captured["body"]["unscoped_levels"] == [0, 1] + assert captured["body"]["unscoped_ancestors"] == ["anc-1", "anc-2"] + + client.update_api_collection("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + + +def test_add_api_folder_deprecated_alias_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_management_client(handler) + client.add_api_folder( + "api-1", + "folder-1", + unscoped_levels=[0], + unscoped_ancestors=["anc-1"], + ) + assert captured["body"]["unscoped_levels"] == [0] + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + + client.add_api_folder("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] + + +def test_update_api_folder_deprecated_alias_sends_unscoped_fields_when_given_and_omits_when_not(): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(200, json=API_FOLDER_JSON | captured["body"]) + + client = build_management_client(handler) + client.update_api_folder( + "api-1", + "folder-1", + unscoped_levels=[0, 1], + unscoped_ancestors=["anc-1", "anc-2"], + ) + assert captured["body"]["unscoped_levels"] == [0, 1] + assert captured["body"]["unscoped_ancestors"] == ["anc-1", "anc-2"] + + client.update_api_folder("api-1", "folder-1") + assert "unscoped_levels" not in captured["body"] + assert "unscoped_ancestors" not in captured["body"] diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py index efc5954..115e650 100644 --- a/tests/test_collection_type_aliases.py +++ b/tests/test_collection_type_aliases.py @@ -54,7 +54,7 @@ def test_top_level_package_reexports_collection_types(): def test_version_string_matches_pyproject(): """Pin the declared package version (single-sourced from _version.py, which the build backend also reads for the distribution version).""" - assert foxnose_sdk.__version__ == "0.7.1" + assert foxnose_sdk.__version__ == "0.8.0" def test_user_agent_tracks_version(): From 2cb96966af92261c946507c54a35f9c9ffe48e1f Mon Sep 17 00:00:00 2001 From: loookashow Date: Sat, 8 Aug 2026 19:36:05 +0200 Subject: [PATCH 2/5] docs: fix false Flux write-address claim; pin server-side validation decisions docs/flux-client.md wrongly claimed FluxClient has no method that can target a flat address -- create_resource()/update_resource() accept the same opaque folder_path as the read methods and will happily interpolate a flat path; it is the server that rejects the write. Reworded to match the TS README. Also pins two "the server validates, not us" decisions with regression tests (sync + async): an out-of-range/non-integer truncate_text and a lone unscoped_levels or unscoped_ancestors must keep forwarding to the request unchanged, not raise, so client-side validation can't creep in unnoticed. --- docs/flux-client.md | 5 ++- tests/test_async_clients.py | 67 +++++++++++++++++++++++++++++++++++++ tests/test_clients.py | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/docs/flux-client.md b/docs/flux-client.md index 34a81aa..79ee6b8 100644 --- a/docs/flux-client.md +++ b/docs/flux-client.md @@ -307,9 +307,8 @@ client.list_resources("realty/accounts/acc_1/listings/photos") This works today for List Resources, Get Resource, and Schema — the folder path is an opaque, slash-trimmed string with no segment parsing or ancestor -validation on the client side. **Writes cannot target a flat address**: -`FluxClient` has no method that would let you, and the server rejects writes -on flat paths regardless. +validation on the client side. These addresses are read-only; the server +rejects writes on a flat path. Not every configured level necessarily serves every read method. Before relying on a flat address, check `enabled`, `available`, and `read_methods` diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py index bc82a23..f74e5b9 100644 --- a/tests/test_async_clients.py +++ b/tests/test_async_clients.py @@ -2578,3 +2578,70 @@ def test_api_folder_summary_preserves_unknown_fields_via_extra_allow_async_file( summary = APIFolderSummary.model_validate(payload) assert summary.model_extra["some_future_top_level_flag"] is True assert summary.flat_routes[0].model_extra["some_future_route_flag"] == "x" + + +# --------------------------------------------------------------------------- +# Pins for two "the server validates, not us" decisions: truncate_text bounds +# and the unscoped_levels/unscoped_ancestors pairing are both enforced by the +# server, not the SDK. These tests exist ONLY to fail loudly if a future +# change accidentally adds client-side validation for either -- they assert +# that the value/field reaches the request, not that it is "correct". Async +# mirror of tests/test_clients.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_flux_search_forwards_out_of_range_and_non_integer_truncate_text_without_raising(): + """Regression pin: truncate_text bounds (integer, >= 1) are validated by + the server via a 422, not the SDK. An out-of-range value (0) and a + non-integer value must keep forwarding to the query string unchanged -- + not raise -- or client-side validation has crept into the SDK. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_async_flux_client(handler) + + await flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": 0}, + ) + assert captured["query"]["truncate_text"] == "0" + + await flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": "not-an-integer"}, + ) + assert captured["query"]["truncate_text"] == "not-an-integer" + await flux.aclose() + + +@pytest.mark.asyncio +async def test_async_add_api_collection_forwards_unscoped_levels_or_unscoped_ancestors_alone_without_requiring_both(): + """Regression pin: the API requires unscoped_levels and unscoped_ancestors + to be sent together, but enforcing that pairing is the server's job (see + add_api_collection's docstring), not the SDK's. Either field alone must + still reach the request body -- if this starts raising, client-side + pairing validation has crept into the SDK. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_async_management_client(handler) + + await client.add_api_collection("api-1", "folder-1", unscoped_levels=[0]) + assert captured["body"]["unscoped_levels"] == [0] + assert "unscoped_ancestors" not in captured["body"] + + await client.add_api_collection("api-1", "folder-1", unscoped_ancestors=["anc-1"]) + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + assert "unscoped_levels" not in captured["body"] + await client.aclose() diff --git a/tests/test_clients.py b/tests/test_clients.py index 9f34198..ebb589e 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -2782,3 +2782,65 @@ def handler(request: httpx.Request) -> httpx.Response: client.update_api_folder("api-1", "folder-1") assert "unscoped_levels" not in captured["body"] assert "unscoped_ancestors" not in captured["body"] + + +# --------------------------------------------------------------------------- +# Pins for two "the server validates, not us" decisions: truncate_text bounds +# and the unscoped_levels/unscoped_ancestors pairing are both enforced by the +# server, not the SDK. These tests exist ONLY to fail loudly if a future +# change accidentally adds client-side validation for either -- they assert +# that the value/field reaches the request, not that it is "correct". +# --------------------------------------------------------------------------- + + +def test_flux_search_forwards_out_of_range_and_non_integer_truncate_text_without_raising(): + """Regression pin: truncate_text bounds (integer, >= 1) are validated by + the server via a 422, not the SDK. An out-of-range value (0) and a + non-integer value must keep forwarding to the query string unchanged -- + not raise -- or client-side validation has crept into the SDK. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=SEARCH_RESPONSE) + + flux = _build_flux_client(handler) + + flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": 0}, + ) + assert captured["query"]["truncate_text"] == "0" + + flux.search( + "articles", + body={"find_text": {"query": "hello"}}, + params={"truncate_text": "not-an-integer"}, + ) + assert captured["query"]["truncate_text"] == "not-an-integer" + + +def test_add_api_collection_forwards_unscoped_levels_or_unscoped_ancestors_alone_without_requiring_both(): + """Regression pin: the API requires unscoped_levels and unscoped_ancestors + to be sent together, but enforcing that pairing is the server's job (see + add_api_collection's docstring), not the SDK's. Either field alone must + still reach the request body -- if this starts raising, client-side + pairing validation has crept into the SDK. + """ + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(201, json=API_FOLDER_JSON | captured["body"]) + + client = build_management_client(handler) + + client.add_api_collection("api-1", "folder-1", unscoped_levels=[0]) + assert captured["body"]["unscoped_levels"] == [0] + assert "unscoped_ancestors" not in captured["body"] + + client.add_api_collection("api-1", "folder-1", unscoped_ancestors=["anc-1"]) + assert captured["body"]["unscoped_ancestors"] == ["anc-1"] + assert "unscoped_levels" not in captured["body"] From dd0b77c618ba7b0dde82c64bb2172b1a1ab90e27 Mon Sep 17 00:00:00 2001 From: loookashow Date: Sun, 9 Aug 2026 10:09:22 +0200 Subject: [PATCH 3/5] style: apply ruff format to the test suite CI only runs 'ruff format --check src/', so tests/ has never been gated and had drifted. Formatting only; no behavioural change. 378 tests still pass. --- tests/test_collections_methods.py | 8 ++----- tests/test_collections_methods_async.py | 12 +++------- tests/test_errors.py | 32 +++++++++++++++++++------ tests/test_http_transport.py | 8 +++++-- tests/test_sync_collection_component.py | 5 +++- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/tests/test_collections_methods.py b/tests/test_collections_methods.py index bdcbc63..c23d1e4 100644 --- a/tests/test_collections_methods.py +++ b/tests/test_collections_methods.py @@ -236,9 +236,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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"] - ) + 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" @@ -318,9 +316,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(201, json=FIELD_JSON) client = build_management_client(handler) - client.create_collection_field( - "coll-1", "v1", {"name": "title", "type": "string"} - ) + client.create_collection_field("coll-1", "v1", {"name": "title", "type": "string"}) assert captured["body"]["name"] == "title" diff --git a/tests/test_collections_methods_async.py b/tests/test_collections_methods_async.py index 79fd280..51ee6dd 100644 --- a/tests/test_collections_methods_async.py +++ b/tests/test_collections_methods_async.py @@ -262,9 +262,7 @@ def handler(req: httpx.Request) -> httpx.Response: 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) - ] + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] assert len(deprecations) == 1 assert "list_folders" in str(deprecations[0].message) @@ -295,9 +293,7 @@ def handler(req: httpx.Request) -> httpx.Response: 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) - ] + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] assert deprecations assert "add_api_folder" in str(deprecations[0].message) @@ -312,8 +308,6 @@ def handler(req: httpx.Request) -> httpx.Response: 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) - ] + 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_errors.py b/tests/test_errors.py index d573278..410e98f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -132,7 +132,9 @@ def handler(request: httpx.Request) -> httpx.Response: }, ) - transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=0)) + transport = _transport( + handler, retry_config=RetryConfig(attempts=3, backoff_factor=0) + ) with pytest.raises(PlanExhausted): transport.request("GET", "/v1/test") assert attempts["count"] == 1 @@ -202,7 +204,9 @@ def handler(request: httpx.Request) -> httpx.Response: headers={"Retry-After": "42"}, ) - transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=0)) + 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 @@ -224,7 +228,9 @@ def handler(request: httpx.Request) -> httpx.Response: headers={"Retry-After": "0"}, ) - transport = _transport(handler, retry_config=RetryConfig(attempts=3, backoff_factor=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 @@ -240,7 +246,9 @@ def handler(request: httpx.Request) -> httpx.Response: headers={"Retry-After": "not-a-number"}, ) - transport = _transport(handler, retry_config=RetryConfig(attempts=1, backoff_factor=0)) + 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 @@ -263,7 +271,9 @@ def handler(request: httpx.Request) -> httpx.Response: json={"error_code": "insufficient_units", "message": "No units left"}, ) - transport = _transport(handler, retry_config=RetryConfig(attempts=1, backoff_factor=0)) + 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 @@ -328,10 +338,18 @@ def test_base_except_catches_each_subclass(): ), ] for status_code, json_body, headers in cases: - def handler(request: httpx.Request, _json=json_body, _status=status_code, _headers=headers) -> httpx.Response: + + 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)) + transport = _transport( + handler, retry_config=RetryConfig(attempts=1, backoff_factor=0) + ) with pytest.raises(FoxnoseAPIError): transport.request("GET", "/v1/test") diff --git a/tests/test_http_transport.py b/tests/test_http_transport.py index c51650b..e465265 100644 --- a/tests/test_http_transport.py +++ b/tests/test_http_transport.py @@ -31,14 +31,18 @@ def _api_error(status_code, error_code, *, detail=None, body=None): def test_build_api_error_collection_not_writable(): - err = _api_error(403, "collection_not_writable", body={"error_code": "collection_not_writable"}) + err = _api_error( + 403, "collection_not_writable", body={"error_code": "collection_not_writable"} + ) assert isinstance(err, CollectionNotWritable) assert isinstance(err, FoxnoseAPIError) assert err.status_code == 403 def test_build_api_error_external_id_conflict(): - err = _api_error(409, "external_id_conflict", body={"error_code": "external_id_conflict"}) + err = _api_error( + 409, "external_id_conflict", body={"error_code": "external_id_conflict"} + ) assert isinstance(err, ExternalIdConflict) diff --git a/tests/test_sync_collection_component.py b/tests/test_sync_collection_component.py index 1d29295..8baff0d 100644 --- a/tests/test_sync_collection_component.py +++ b/tests/test_sync_collection_component.py @@ -36,7 +36,9 @@ } -def _build_client(handler: Callable[[httpx.Request], httpx.Response]) -> ManagementClient: +def _build_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> ManagementClient: client = ManagementClient( base_url="https://api.example.com", environment_key=ENV_KEY, @@ -132,6 +134,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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") From 05a5fa84d3df134ba42351f60b259c9db6d4877e Mon Sep 17 00:00:00 2001 From: loookashow Date: Sun, 9 Aug 2026 10:20:21 +0200 Subject: [PATCH 4/5] ci: gate the whole repo with ruff; add an opt-in pre-push hook CI linted src/ only, so tests/ drifted unchecked until it needed a repo-wide reformat. Both ruff commands now cover the repo. Two unused imports in tests/ are removed so the widened gate starts green. .githooks/pre-push runs the same checks locally. Opt in per clone with git config core.hooksPath .githooks It is skippable with --no-verify, so CI stays the real gate. --- .githooks/pre-push | 38 +++++++++++++++++++++++++ .github/workflows/ci.yml | 6 ++-- CONTRIBUTING.md | 16 ++++++++++- tests/test_collections_methods.py | 2 +- tests/test_collections_methods_async.py | 2 +- 5 files changed, 59 insertions(+), 5 deletions(-) create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..8565c37 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,38 @@ +#!/bin/sh +# +# Runs the checks CI gates on, before the push leaves your machine. +# +# Enable once per clone: +# git config core.hooksPath .githooks +# +# Bypass for a genuine emergency: +# git push --no-verify +# +# This hook is a fast feedback loop, not a security boundary — it is opt-in per +# clone and skippable. CI remains the real gate. + +set -e + +fail() { + echo "" + echo "pre-push: $1 failed." + echo "pre-push: fix with $2" + echo "pre-push: or skip with git push --no-verify" + exit 1 +} + +if ! command -v ruff >/dev/null 2>&1; then + echo "pre-push: ruff not found on PATH — skipping lint and format checks." + echo "pre-push: install it with pip install ruff==0.13.2" +else + echo "pre-push: ruff check ." + ruff check . || fail "ruff check" "ruff check --fix ." + + echo "pre-push: ruff format --check ." + ruff format --check . || fail "ruff format" "ruff format ." +fi + +echo "pre-push: pytest" +pytest -q || fail "pytest" "pytest -q" + +echo "pre-push: ok" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dd7373..8550fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,10 @@ jobs: # with no code change. Bump deliberately alongside a repo-wide reformat. run: pip install ruff==0.13.2 + # Gates the whole repo, not just src/. Linting src/ alone let tests/ drift + # unchecked until it needed a repo-wide reformat to get back in line. - name: Run ruff check - run: ruff check src/ + run: ruff check . - name: Run ruff format check - run: ruff format --check src/ + run: ruff format --check . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4926046..aa8d456 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,9 +38,11 @@ pip install -e ".[test]" 4. Install linting tools: ```bash -pip install ruff +pip install ruff==0.13.2 ``` +Pin the version. CI pins it too: an unpinned `ruff` resolves to the latest release, so a new version with changed default rules can turn a green repo red with no code change. + ## Development Workflow ### Code Style @@ -55,6 +57,18 @@ ruff check . ruff format . ``` +Both commands cover the whole repo, including `tests/` — that is what CI gates on. + +### Checks before pushing + +To run the same checks locally on every `git push`, enable the repo's hook directory once per clone: + +```bash +git config core.hooksPath .githooks +``` + +`.githooks/pre-push` then runs `ruff check`, `ruff format --check` and `pytest`, and aborts the push if any fail. It is opt-in per clone and can be skipped with `git push --no-verify`, so it is a fast feedback loop rather than a guarantee — CI remains the real gate. + ### Type Hints All public APIs should include type hints. We use Python's built-in `typing` module and Pydantic for model definitions. diff --git a/tests/test_collections_methods.py b/tests/test_collections_methods.py index c23d1e4..82b79dd 100644 --- a/tests/test_collections_methods.py +++ b/tests/test_collections_methods.py @@ -12,7 +12,7 @@ import json import warnings -from typing import Any, Callable +from typing import Callable import httpx import pytest diff --git a/tests/test_collections_methods_async.py b/tests/test_collections_methods_async.py index 51ee6dd..5f0997c 100644 --- a/tests/test_collections_methods_async.py +++ b/tests/test_collections_methods_async.py @@ -10,7 +10,7 @@ import json import warnings -from typing import Any, Callable +from typing import Callable import httpx import pytest From de214277bf60c9b29b35f38d4ad724757acc9d17 Mon Sep 17 00:00:00 2001 From: loookashow Date: Mon, 10 Aug 2026 10:29:23 +0200 Subject: [PATCH 5/5] chore: move the version bump and release notes to a release PR --- .gitignore | 2 + docs/changelog.md | 71 +-------------------------- src/foxnose_sdk/_version.py | 2 +- tests/test_collection_type_aliases.py | 2 +- 4 files changed, 5 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index f0aeee9..75f93cc 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,5 @@ site/ # OS .DS_Store Thumbs.db + +.claude/ diff --git a/docs/changelog.md b/docs/changelog.md index 644d7e9..705c9df 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,74 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.8.0] - 2026-08-08 - -### Added - -- **`truncate_text` query parameter on Flux search.** `FluxClient.search()` / - `AsyncFluxClient.search()` now accept a keyword-only `params` mapping, - forwarded to the query string (the transport already supported this; it - was not plumbed through `search()`). Combined with the existing - passthrough on `list_resources()`, both List Resources and Search now - support `truncate_text` — an integer ≥ 1 that caps every `text`-typed - field in the response and is ignored when `raw=true`. The response marks - truncated fields under `_sys.truncated` (each entry has `field`, `locale` - — `null` for non-localized fields — and `original_length`); fields within - the limit get no entry. There is no client-side validation: an invalid - value (non-integer or < 1) surfaces as a server `422 validation_error`. -- **`query_params` on the four search wrappers** — `vector_search()`, - `vector_field_search()`, `hybrid_search()`, `boosted_search()` (sync and - async) — forwarded to the query string, so `truncate_text` (or any other - query parameter) can be combined with the typed search helpers. Named - `query_params` rather than `params` deliberately: `params` was already a - meaningful key inside `**extra_body` (forwarded to the JSON body), and an - explicit `params` keyword would have silently rerouted it to the query - string for any existing caller passing `params={...}` as a search-body - field. `_merge_extra` now raises if handed `truncate_text` as a body - field, naming `query_params` as the correct place for it. -- **Cross-parent (flat) read addressing** for strict-reference collections. - `add_api_collection` / `update_api_collection` (and the deprecated - `add_api_folder` / `update_api_folder` aliases), sync and async, accept - `unscoped_levels: list[int]` and `unscoped_ancestors: list[str]` to expose - additional read-only addresses that drop (fully-flat, `level == 0`) or - partially drop (`level >= 1`, retaining the root-most ancestor keys) the - ancestor chain of a nested collection path. The API requires the two to be - sent together — not enforced client-side, since the server owns that rule - and may relax it. - - `APIFolderSummary` (aliased `APICollectionSummary`) gains - `unscoped_levels`, `unscoped_ancestors`, `expose_owner` (all default to - an empty/false value if the server omits them) and `flat_route` / - `flat_routes` (both optional *and* nullable — observed `null` on - connections with no key-bearing ancestor). New `FlatRoute` model for - each entry of `flat_routes`; new `FlatRouteSummary` model for the - singular `flat_route`, which is undocumented and mirrors whichever - route is currently enabled — no behavior is built on it. - - `APIFolderSummary` and both new route models now set - `model_config = ConfigDict(extra="allow")`, so unrecognized fields - (present or future) are preserved instead of silently dropped. - - List, Get Resource, and Schema already worked against these addresses - (the collection path is an opaque, slash-trimmed string); this release - only adds typed support for the connection object and for configuring - the addresses. - - Cross-parent writes are out of scope: `FluxClient` has no create/update - method that takes a folder path other than the ones already documented, - and the server rejects writes on flat paths regardless. - - Not addressed by this release, and not planned to be worked around in - the SDK: `flat_routes[].read_methods` reports `["get_one", "get_many"]` - at level 0 even though the docs describe Get Resource as available only - at `level >= 1`, and `read_methods` never includes `search` at any - level despite the docs listing Search as available on flat addresses. - Both are raised with the API team; `read_methods` is typed as sent. - -### Fixed - -- The default `DEFAULT_USER_AGENT` was pinned to `foxnose-sdk/0.1.0` - regardless of the installed version. The version now lives in a single - `_version.py` module imported by both `foxnose_sdk/__init__.py` (so - `foxnose_sdk.__version__` keeps working) and `config.py` (so the - User-Agent reports the real version), avoiding the circular import that - motivated the stale constant in the first place. - ## [0.7.1] - 2026-07-24 ### Added @@ -250,8 +182,7 @@ 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.8.0...HEAD -[0.8.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...v0.8.0 +[Unreleased]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.1...HEAD [0.7.1]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/FoxNoseTech/foxnose-python/compare/v0.5.0...v0.6.0 diff --git a/src/foxnose_sdk/_version.py b/src/foxnose_sdk/_version.py index b7c7fe2..18fcd79 100644 --- a/src/foxnose_sdk/_version.py +++ b/src/foxnose_sdk/_version.py @@ -4,4 +4,4 @@ by the build backend (see ``[tool.hatch.version]`` in ``pyproject.toml``). """ -__version__ = "0.8.0" +__version__ = "0.7.1" diff --git a/tests/test_collection_type_aliases.py b/tests/test_collection_type_aliases.py index 115e650..efc5954 100644 --- a/tests/test_collection_type_aliases.py +++ b/tests/test_collection_type_aliases.py @@ -54,7 +54,7 @@ def test_top_level_package_reexports_collection_types(): def test_version_string_matches_pyproject(): """Pin the declared package version (single-sourced from _version.py, which the build backend also reads for the distribution version).""" - assert foxnose_sdk.__version__ == "0.8.0" + assert foxnose_sdk.__version__ == "0.7.1" def test_user_agent_tracks_version():