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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,5 @@ site/
# OS
.DS_Store
Thumbs.db

.claude/
16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
102 changes: 99 additions & 3 deletions docs/flux-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -212,17 +216,109 @@ resources = client.list_resources(

### Pagination

`next` in the response is a full absolute URL (e.g.
`https://<env>.fxns.io/<prefix>/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>"})
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. 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`
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
Expand Down
52 changes: 52 additions & 0 deletions docs/management-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/foxnose_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
EnvironmentSummary,
FieldList,
FieldSummary,
FlatRoute,
FlatRouteSummary,
FolderList,
FolderSummary,
FluxAPIKeyList,
Expand Down Expand Up @@ -157,6 +159,8 @@
"SchemaVersionList",
"FieldSummary",
"FieldList",
"FlatRoute",
"FlatRouteSummary",
"ProjectSummary",
"ProjectList",
"EnvironmentSummary",
Expand Down
Loading
Loading