bkn-osdk 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bkn_osdk-0.2.1/.gitignore +60 -0
- bkn_osdk-0.2.1/PKG-INFO +600 -0
- bkn_osdk-0.2.1/README.md +573 -0
- bkn_osdk-0.2.1/README.zh.md +371 -0
- bkn_osdk-0.2.1/bkn_osdk/__init__.py +122 -0
- bkn_osdk-0.2.1/bkn_osdk/auth.py +199 -0
- bkn_osdk-0.2.1/bkn_osdk/codegen/__init__.py +11 -0
- bkn_osdk-0.2.1/bkn_osdk/codegen/cli.py +375 -0
- bkn_osdk-0.2.1/bkn_osdk/codegen/diff.py +110 -0
- bkn_osdk-0.2.1/bkn_osdk/codegen/emit.py +619 -0
- bkn_osdk-0.2.1/bkn_osdk/codegen/kn_rest.py +233 -0
- bkn_osdk-0.2.1/bkn_osdk/config.py +522 -0
- bkn_osdk-0.2.1/bkn_osdk/errors.py +207 -0
- bkn_osdk-0.2.1/bkn_osdk/http.py +245 -0
- bkn_osdk-0.2.1/bkn_osdk/identity.py +88 -0
- bkn_osdk-0.2.1/bkn_osdk/kn.py +1411 -0
- bkn_osdk-0.2.1/bkn_osdk/lifecycle.py +354 -0
- bkn_osdk-0.2.1/bkn_osdk/mcp.py +393 -0
- bkn_osdk-0.2.1/bkn_osdk/meta.py +181 -0
- bkn_osdk-0.2.1/bkn_osdk/metrics.py +234 -0
- bkn_osdk-0.2.1/bkn_osdk/oauth.py +701 -0
- bkn_osdk-0.2.1/bkn_osdk/py.typed +0 -0
- bkn_osdk-0.2.1/bkn_osdk/query.py +659 -0
- bkn_osdk-0.2.1/bkn_osdk/schema.py +393 -0
- bkn_osdk-0.2.1/bkn_osdk/search.py +137 -0
- bkn_osdk-0.2.1/bkn_osdk/subgraph.py +251 -0
- bkn_osdk-0.2.1/bkn_osdk/types.py +456 -0
- bkn_osdk-0.2.1/pyproject.toml +79 -0
- bkn_osdk-0.2.1/tests/conftest.py +79 -0
- bkn_osdk-0.2.1/tests/e2e/__init__.py +2 -0
- bkn_osdk-0.2.1/tests/e2e/conftest.py +155 -0
- bkn_osdk-0.2.1/tests/e2e/test_live.py +467 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/__init__.py +85 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/_meta.py +10 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/metrics.py +9 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/object_types.py +72 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/py.typed +0 -0
- bkn_osdk-0.2.1/tests/fixtures/golden/demo/relation_types.py +22 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/count_filtered.json +48 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/count_unfiltered.json +42 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/flat_condition.json +73 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/get_by_primary_key.json +46 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/in_operator.json +100 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/nested_and_or.json +95 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/no_match.json +18 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/offset_paging.json +68 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/property_selection.json +32 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/sorted_desc.json +73 -0
- bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/unfiltered_page.json +67 -0
- bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/network.json +32 -0
- bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/object_types.json +3833 -0
- bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/relation_types.json +941 -0
- bkn_osdk-0.2.1/tests/schema_fixtures.py +72 -0
- bkn_osdk-0.2.1/tests/test_app_tokens.py +249 -0
- bkn_osdk-0.2.1/tests/test_auth.py +290 -0
- bkn_osdk-0.2.1/tests/test_cli.py +463 -0
- bkn_osdk-0.2.1/tests/test_config.py +283 -0
- bkn_osdk-0.2.1/tests/test_emit.py +340 -0
- bkn_osdk-0.2.1/tests/test_errors.py +170 -0
- bkn_osdk-0.2.1/tests/test_generated_package.py +151 -0
- bkn_osdk-0.2.1/tests/test_http.py +285 -0
- bkn_osdk-0.2.1/tests/test_identity.py +110 -0
- bkn_osdk-0.2.1/tests/test_kn_rest.py +320 -0
- bkn_osdk-0.2.1/tests/test_lifecycle_retry.py +244 -0
- bkn_osdk-0.2.1/tests/test_metrics_and_negation.py +457 -0
- bkn_osdk-0.2.1/tests/test_oauth.py +489 -0
- bkn_osdk-0.2.1/tests/test_platform_floor.py +112 -0
- bkn_osdk-0.2.1/tests/test_query.py +573 -0
- bkn_osdk-0.2.1/tests/test_relations.py +190 -0
- bkn_osdk-0.2.1/tests/test_schema.py +127 -0
- bkn_osdk-0.2.1/tests/test_schema_details.py +88 -0
- bkn_osdk-0.2.1/tests/test_schema_parse.py +351 -0
- bkn_osdk-0.2.1/tests/test_search_and_drift.py +345 -0
- bkn_osdk-0.2.1/tests/test_subgraph.py +492 -0
- bkn_osdk-0.2.1/tests/test_traced.py +1153 -0
- bkn_osdk-0.2.1/tests/test_types.py +243 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
# No trailing slash: that form matches directories only, and a symlinked
|
|
3
|
+
# node_modules — how a worktree usually borrows the parent's install — is not
|
|
4
|
+
# a directory, so it slipped past and got committed.
|
|
5
|
+
node_modules
|
|
6
|
+
.pnp/
|
|
7
|
+
.pnp.js
|
|
8
|
+
|
|
9
|
+
# Build output
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
.tmp-example/
|
|
13
|
+
*.tsbuildinfo
|
|
14
|
+
|
|
15
|
+
# Python (python/ — the OSDK runtime + generator)
|
|
16
|
+
__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
.venv/
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
*.egg-info/
|
|
23
|
+
|
|
24
|
+
# Test / coverage
|
|
25
|
+
coverage/
|
|
26
|
+
.nyc_output/
|
|
27
|
+
test-results/
|
|
28
|
+
*.lcov
|
|
29
|
+
|
|
30
|
+
# Logs
|
|
31
|
+
*.log
|
|
32
|
+
npm-debug.log*
|
|
33
|
+
pnpm-debug.log*
|
|
34
|
+
yarn-debug.log*
|
|
35
|
+
yarn-error.log*
|
|
36
|
+
|
|
37
|
+
# Env / secrets
|
|
38
|
+
.env
|
|
39
|
+
.env.*
|
|
40
|
+
!.env.example
|
|
41
|
+
*.local
|
|
42
|
+
|
|
43
|
+
# Local CLI state (BKN tokens/config — never commit)
|
|
44
|
+
.bkn/
|
|
45
|
+
|
|
46
|
+
# Editor / OS
|
|
47
|
+
.DS_Store
|
|
48
|
+
.idea/
|
|
49
|
+
.vscode/*
|
|
50
|
+
!.vscode/extensions.json
|
|
51
|
+
*.swp
|
|
52
|
+
|
|
53
|
+
# Package manager artifacts
|
|
54
|
+
.npm/
|
|
55
|
+
*.tgz
|
|
56
|
+
|
|
57
|
+
.cursor/
|
|
58
|
+
|
|
59
|
+
# Claude Code local worktrees
|
|
60
|
+
.claude/
|
bkn_osdk-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bkn-osdk
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Generated, read-only ontology SDK for BKN knowledge networks
|
|
5
|
+
Project-URL: Homepage, https://github.com/openbkn-ai/bkn-sdk
|
|
6
|
+
Project-URL: Source, https://github.com/openbkn-ai/bkn-sdk/tree/main/python
|
|
7
|
+
Project-URL: Issues, https://github.com/openbkn-ai/bkn-sdk/issues
|
|
8
|
+
Author: OpenBKN
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: bkn,knowledge-network,ontology,osdk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
|
+
Requires-Dist: types-pyyaml>=6; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# bkn-osdk
|
|
29
|
+
|
|
30
|
+
*[中文版](README.zh.md)*
|
|
31
|
+
|
|
32
|
+
A Python SDK for BKN, in two layers.
|
|
33
|
+
|
|
34
|
+
**The ontology layer** is typed, read-only, and specific to **one** knowledge
|
|
35
|
+
network — generated from that network's schema, the way Palantir's OSDK is:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from bkn.object_types import People
|
|
39
|
+
|
|
40
|
+
People.where(People.age > 30).take(10)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**The platform layer** addresses the platform itself: any REST route by path,
|
|
44
|
+
and any tool the deploy publishes by name.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
bkn_osdk.call("/api/agent-observability/v1/traces", query={"limit": 10})
|
|
48
|
+
bkn_osdk.call_tool(ctx, kn_id, "describe_resource", {"resource_id": "…", ...})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The layers are named for what they address, not for how they were built — one
|
|
52
|
+
happens to be generated and the other hand-written, which is an implementation
|
|
53
|
+
fact rather than something a caller reasons about. Both go through the same
|
|
54
|
+
credentials, the same transports, and the same managed turn, so dropping to the
|
|
55
|
+
platform layer for one call and coming back up keeps the evidence on one chain.
|
|
56
|
+
|
|
57
|
+
This is not a port of the TypeScript SDK. That one wraps eleven backend
|
|
58
|
+
namespaces in HTTP calls, which buys a Python caller nothing that
|
|
59
|
+
`bkn_osdk.call("/api/…")` does not. Generated classes over an ontology cannot be
|
|
60
|
+
replaced by a raw call, which is why they are worth a second language.
|
|
61
|
+
|
|
62
|
+
**Targets platform 0.1.5.** Payloads are taken as the platform sends them, and
|
|
63
|
+
the 0.1.4 line does not send the same ones — it wraps `get_kn_detail`,
|
|
64
|
+
`list_resources`, `list_skills`, `get_object_types` and `list_knowledge_networks`
|
|
65
|
+
in a `result` key, and serves a `semantic-search` route that 0.1.5 withdrew.
|
|
66
|
+
Reading against an older deploy is possible but is the caller's own adaptation,
|
|
67
|
+
not this SDK's.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
From PyPI. The version follows the SDK release that ships it, in PEP 440
|
|
72
|
+
spelling (`v0.2.1-rc.2` is `0.2.1rc2`); pip skips prereleases unless pinned or
|
|
73
|
+
asked with `--pre`:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install "bkn-osdk==0.2.1"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Its only dependency is `httpx`, so an image build is one line:
|
|
80
|
+
|
|
81
|
+
```dockerfile
|
|
82
|
+
RUN pip install --no-cache-dir "bkn-osdk==0.2.1"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A generated package declares the runtime minor line it was built for
|
|
86
|
+
(`REQUIRES_RUNTIME`, `>=0.2,<0.3` by default) and refuses to import under any
|
|
87
|
+
other. Packages generated by the earlier `0.1.0` runtime declare `>=0.1,<0.2`:
|
|
88
|
+
regenerate them with this runtime when upgrading.
|
|
89
|
+
|
|
90
|
+
To install an unreleased commit instead, the package lives in a subdirectory of
|
|
91
|
+
this repository, which pip addresses directly:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install "bkn-osdk @ git+https://github.com/openbkn-ai/bkn-sdk@<sha>#subdirectory=python"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Pin a commit, not a branch.** A direct URL carries no index, so nothing
|
|
98
|
+
resolves a "latest" for you and moving versions means editing that line —
|
|
99
|
+
which is the point. A branch name looks like it saves that edit and does not:
|
|
100
|
+
pip's wheel cache is keyed by URL, so a rebuild against the same branch can
|
|
101
|
+
reinstall the build it already has, reporting success while installing the old
|
|
102
|
+
commit. `direct_url.json` in the installed distribution records which commit
|
|
103
|
+
actually landed.
|
|
104
|
+
|
|
105
|
+
Where git is not installed — the sandbox image is one such place — the archive
|
|
106
|
+
URL takes the same fragment:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install "bkn-osdk @ https://github.com/openbkn-ai/bkn-sdk/archive/<sha>.zip#subdirectory=python"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Generate
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
openbkn auth login https://your-platform # the CLI owns the credential store
|
|
116
|
+
bkn-osdk generate <kn-id> --out ./bkn
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`--out` is the package: Python imports it by that directory's name. The command
|
|
120
|
+
authenticates and fetches the whole schema **before** writing anything, and
|
|
121
|
+
refuses a directory it did not produce, so a mistyped path cannot overwrite
|
|
122
|
+
source. Regenerating an unchanged schema rewrites the same bytes, so a real
|
|
123
|
+
regeneration shows up as a reviewable `git diff`.
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
bkn/
|
|
127
|
+
__init__.py # KN_ID, BRANCH, OBJECT_TYPES, RELATION_TYPES, METRICS,
|
|
128
|
+
# search(), search_instances()
|
|
129
|
+
object_types.py # one class per object type
|
|
130
|
+
relation_types.py # relation endpoints and their join columns
|
|
131
|
+
metrics.py # one class per metric, with the dimensions it allows
|
|
132
|
+
_meta.py # fingerprint, format version, runtime range
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Commit the generated package, pin `bkn-osdk~=0.1`, and run `bkn-osdk check` in
|
|
136
|
+
CI: it compares the live schema against the fingerprint and exits non-zero on
|
|
137
|
+
drift, classifying it as *additive* (new object type or property — existing code
|
|
138
|
+
keeps working) or *breaking* (removed, retyped, or rekeyed).
|
|
139
|
+
|
|
140
|
+
Object-type catalogues may contain summaries. Generation fetches missing object
|
|
141
|
+
properties from each public detail endpoint using the same caller and branch;
|
|
142
|
+
a missing or mismatched detail stops generation instead of silently dropping
|
|
143
|
+
properties. Metric UUIDs starting with digits receive a `Metric` class-name
|
|
144
|
+
prefix; the original ID remains unchanged on requests. Multi-hop traversal keeps
|
|
145
|
+
the starting row's bound connection and caller context unless the call supplies
|
|
146
|
+
an explicit context.
|
|
147
|
+
|
|
148
|
+
## Credentials
|
|
149
|
+
|
|
150
|
+
Both commands take `--base-url`, `--token` or `--token-file`, `--user` and
|
|
151
|
+
`--insecure`, which beat the environment and the store. That is what a CI job
|
|
152
|
+
wants, and what lets one Makefile generate from two deploys without editing its
|
|
153
|
+
own environment between the calls:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bkn-osdk generate <kn-id> --out ./bkn --base-url https://staging --token-file /run/secrets/bkn
|
|
157
|
+
bkn-osdk check ./bkn --base-url https://staging --token-file - # or read it from stdin
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A token passed as `--token` is visible in `ps` and in shell history; outside a
|
|
161
|
+
local shell, mount it and use `--token-file`.
|
|
162
|
+
|
|
163
|
+
Nothing is compiled into the generated package: the same KN commonly exists on a
|
|
164
|
+
dev and a prod platform, so it pins only `kn_id` and `branch`. Credentials
|
|
165
|
+
resolve at call time, innermost first:
|
|
166
|
+
|
|
167
|
+
1. the active `session(...)` scope,
|
|
168
|
+
2. the process default from `bkn_osdk.configure(...)`,
|
|
169
|
+
3. `BKN_TOKEN` / `BKN_BASE_URL`,
|
|
170
|
+
4. `~/.bkn/…/token.json` — the store `openbkn auth login` writes.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import bkn_osdk
|
|
174
|
+
from bkn_osdk import session
|
|
175
|
+
from bkn.object_types import People
|
|
176
|
+
|
|
177
|
+
People.take(10) # notebook: nothing to configure
|
|
178
|
+
|
|
179
|
+
bkn_osdk.configure(base_url=PROD, token=TOKEN) # one platform, whole process
|
|
180
|
+
|
|
181
|
+
with session(token=user_token): # a scope per request
|
|
182
|
+
People.take(10)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The scope lives in a `ContextVar`, so threads and asyncio tasks each carry their
|
|
186
|
+
own — a multi-tenant server cannot leak one user's token into another's request.
|
|
187
|
+
|
|
188
|
+
A stored session refreshes itself when its access token expires mid-process. If
|
|
189
|
+
the platform rotates the refresh token, the replacement is written back to the
|
|
190
|
+
store, because spending the CLI's credential and keeping the new one would force
|
|
191
|
+
a re-login.
|
|
192
|
+
|
|
193
|
+
## Sign users in to your app
|
|
194
|
+
|
|
195
|
+
An app that acts as its own users needs an OAuth token per user and has to keep
|
|
196
|
+
it alive. `bkn_osdk.oauth` does the sign-in (PKCE, the loopback callback, the
|
|
197
|
+
code exchange) and the refresh grant, and `configure` / `session` refresh the
|
|
198
|
+
tokens it returns the way they refresh a stored session:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import bkn_osdk
|
|
202
|
+
from bkn_osdk import oauth
|
|
203
|
+
|
|
204
|
+
# Once: a public client for the app (or ask an operator for one).
|
|
205
|
+
client = oauth.register_client(PLATFORM, "My app")
|
|
206
|
+
|
|
207
|
+
# A CLI, desktop app, script or notebook: opens the browser, waits on
|
|
208
|
+
# http://127.0.0.1:8765/callback, exchanges the code.
|
|
209
|
+
tokens = oauth.login(PLATFORM, client_id=client.client_id)
|
|
210
|
+
save(tokens.to_dict()) # keep it; see the caveats below
|
|
211
|
+
|
|
212
|
+
bkn_osdk.configure(
|
|
213
|
+
base_url=PLATFORM,
|
|
214
|
+
tokens=tokens,
|
|
215
|
+
client_id=client.client_id, # refresh must name the client the tokens were issued to
|
|
216
|
+
on_refresh=lambda fresh: save(fresh.to_dict()),
|
|
217
|
+
)
|
|
218
|
+
People.take(10) # refreshed shortly before expiry and on a 401
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A server with many users binds each request's tokens instead; the scope is a
|
|
222
|
+
`ContextVar`, so one user's tokens never reach another's request:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
with bkn_osdk.session(tokens=tokens_for(user), client_id=CLIENT_ID, on_refresh=save_for(user)):
|
|
226
|
+
People.take(10)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
A web app receives the callback in its own handler. Keep the `AuthorizationRequest`
|
|
230
|
+
(it holds the `state` and PKCE verifier) in the user's server-side session:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
req = oauth.authorization_request(PLATFORM, CLIENT_ID, redirect_uri="https://app.example/cb")
|
|
234
|
+
# redirect the browser to req.url; later, in the /cb handler:
|
|
235
|
+
tokens = oauth.exchange_code(PLATFORM, req, code=params["code"], state=params["state"])
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
On a machine without a browser, `oauth.device_login(PLATFORM)` prints a code to
|
|
239
|
+
approve elsewhere (pass `on_prompt` to show it yourself); it signs in as the
|
|
240
|
+
CLI's `openbkn-sdk` client, which is also `configure`'s default `client_id`.
|
|
241
|
+
`oauth.refresh(PLATFORM, tokens, client_id=…)` is the refresh grant on its own.
|
|
242
|
+
A sign-in that did not happen raises `AuthError`, with `error` and `description`.
|
|
243
|
+
|
|
244
|
+
Four things to know:
|
|
245
|
+
|
|
246
|
+
1. **Save the refresh token from every callback.** Each refresh spends the old
|
|
247
|
+
one and the platform issues a new one; `on_refresh` receives the whole new
|
|
248
|
+
set. An app that kept only the first must sign the user in again on its next
|
|
249
|
+
start.
|
|
250
|
+
2. **Never refresh one user's tokens from two processes.** The platform treats a
|
|
251
|
+
reused refresh token as theft and revokes the whole chain, signing the user
|
|
252
|
+
out everywhere. One process is safe (concurrent requests share a single
|
|
253
|
+
refresh); several instances need a shared store with a lock, or one place
|
|
254
|
+
that does the refreshing.
|
|
255
|
+
3. **A failed refresh means sign in again.** The refresh token expired, was
|
|
256
|
+
revoked, or the user disconnected the app under connected clients. The
|
|
257
|
+
request then fails with its original 401 and a hint saying so.
|
|
258
|
+
4. **`login` serves loopback callbacks only.** A web app uses
|
|
259
|
+
`authorization_request` + `exchange_code` from its own handler, and an
|
|
260
|
+
operator must allow a non-loopback callback origin for the client.
|
|
261
|
+
`register_client` raises `AuthError` where the platform has no
|
|
262
|
+
self-registration (0.2.0 has none; it can also be disabled): use
|
|
263
|
+
`device_login`, or ask an operator.
|
|
264
|
+
|
|
265
|
+
## Query
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from decimal import Decimal
|
|
269
|
+
from bkn.object_types import Order
|
|
270
|
+
|
|
271
|
+
Order.get(10357) # Order | None
|
|
272
|
+
Order.count() # respects the filter
|
|
273
|
+
Order.where(Order.order_status == "pending_payment").count()
|
|
274
|
+
Order.where((Order.total_amount > Decimal("10000")) & Order.paid_at.exists()).order_by(
|
|
275
|
+
Order.total_amount.desc()
|
|
276
|
+
).select(Order.order_no, Order.total_amount).take(20)
|
|
277
|
+
|
|
278
|
+
for order in Order.iterate(page_size=500): # cursor paging, or limit/offset on older deploys
|
|
279
|
+
...
|
|
280
|
+
|
|
281
|
+
page = Order.objects().page(limit=100)
|
|
282
|
+
next_page = Order.objects().page(limit=100, cursor=page.next_cursor) # None on the last page
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The current ontology-query contract pages by an opaque cursor: the response's
|
|
286
|
+
`paging.next_cursor` is sent back as `cursor` with the same query, and is `null`
|
|
287
|
+
on the last page. Deploys built before that change (0.1.5 releases in the field)
|
|
288
|
+
return no `paging` block but honour `offset`, so `iterate()` follows the cursor
|
|
289
|
+
where the first response carries one and otherwise falls back to `limit`/`offset`,
|
|
290
|
+
stopping on a short page. `page(offset=...)` still sends `offset`; passing both
|
|
291
|
+
`offset` and `cursor` raises.
|
|
292
|
+
|
|
293
|
+
`~` negates a filter where the platform can express it: comparison operators
|
|
294
|
+
invert into each other, `in`/`like`/`exist` have paired negatives, and `and`/`or`
|
|
295
|
+
negate by De Morgan. `match` and `knn` have no opposite in the operator enum, so
|
|
296
|
+
`~` on them raises rather than inventing one.
|
|
297
|
+
|
|
298
|
+
`Order.total_amount` is a `PropertyRef` used to build filters; `order.total_amount`
|
|
299
|
+
is a `Decimal`. One name, two jobs — and both typed, so `People.age > 30` checks
|
|
300
|
+
and `People.name > 30` does not.
|
|
301
|
+
|
|
302
|
+
Decoding follows the declared type: a `decimal` arrives as a JSON string and
|
|
303
|
+
becomes an exact `Decimal`, a `datetime` keeps its offset. A property the query
|
|
304
|
+
did not return raises rather than reading as `None`.
|
|
305
|
+
|
|
306
|
+
Three things about the platform's filter grammar are worth knowing, all verified
|
|
307
|
+
against a live deploy:
|
|
308
|
+
|
|
309
|
+
- `like` semantics belong to the **backing resource**, and the two deploys probed
|
|
310
|
+
disagree: a Postgres-backed object type read the value as a plain substring
|
|
311
|
+
(`like("2026")` matched, `like("2026%")` did not), while a Vega-catalog-backed
|
|
312
|
+
one read it as a SQL pattern (`like("%FIFA%")` matched, `like("FIFA")` did
|
|
313
|
+
not). The value is sent verbatim; a wrong guess returns zero rows, not an
|
|
314
|
+
error, so try both against your own object type.
|
|
315
|
+
- `match` is in the operator enum but a Postgres-backed object type answers 500.
|
|
316
|
+
- `limit` is 1–10000, and `total_count` is omitted entirely when nothing matched.
|
|
317
|
+
|
|
318
|
+
## Aggregate
|
|
319
|
+
|
|
320
|
+
There is no `sum()` or `group_by()` over an object set, because there is no
|
|
321
|
+
endpoint for one: an instance query takes a condition, a sort, a limit, a cursor (or
|
|
322
|
+
offset) and a property selection, and `need_total` returns a row count. Pulling every row back
|
|
323
|
+
to aggregate client-side would be a lie dressed as an API.
|
|
324
|
+
|
|
325
|
+
The platform's aggregation surface is published, versioned **Object Metrics V1**.
|
|
326
|
+
The generated class pins a published version and permits only runtime choices
|
|
327
|
+
declared by that definition:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
from bkn.metrics import Gmv
|
|
331
|
+
|
|
332
|
+
Gmv.query(
|
|
333
|
+
time={"start": 1751328000000, "end": 1753920000000}, # unix ms
|
|
334
|
+
group_by=["channel_id"],
|
|
335
|
+
)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`Gmv.__version__` and `Gmv.__dimensions__` come from the published definition.
|
|
339
|
+
An undeclared or ambiguous dimension is refused locally. `start`/`end` must be
|
|
340
|
+
integer Unix milliseconds; old `instant` and `step` fields are not accepted.
|
|
341
|
+
`analysis_dimensions` is retained as a compatibility keyword, but both forms
|
|
342
|
+
send the canonical runtime field `group_by`.
|
|
343
|
+
Without an interaction, the transport is the bare ontology-query request
|
|
344
|
+
`POST …/object-metrics/{metric_id}/data?version=N`. Inside
|
|
345
|
+
`session(traced=True)`, or when the context already carries conversation and
|
|
346
|
+
interaction IDs, it uses Context Loader's managed `query_metric` tool instead.
|
|
347
|
+
The same query fields and generated branch are forwarded, the managed response
|
|
348
|
+
is normalized to the same V1 `model`, `entries`, `status`, and `warnings`
|
|
349
|
+
envelope, and its receipt is accumulated on the interaction. Lifecycle
|
|
350
|
+
`bkn_context` is never added to ontology-query's strict request body. Fixed
|
|
351
|
+
business qualifiers and statistical grain cannot be overridden at query time.
|
|
352
|
+
|
|
353
|
+
## Traverse
|
|
354
|
+
|
|
355
|
+
One hop is a filter on the target object type, using the join columns the schema
|
|
356
|
+
declares — no session, no second grammar:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
order = Order.get(10357)
|
|
360
|
+
order.order_user.take(10) # User.where(User.user_id == order.user_id)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Several hops have to be joined server-side, which rides the REST subgraph
|
|
364
|
+
endpoint — still an ordinary read, no session:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
Order.order_user.then(User.user_address).of(order, step_limit=20)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
That endpoint's seed-based form walks *every* relation up to the path length, so
|
|
371
|
+
the requested chain is selected from the paths it reports back, and a `where()`
|
|
372
|
+
on the far end is applied locally (it filters only its starting object type).
|
|
373
|
+
Three hops is its ceiling.
|
|
374
|
+
|
|
375
|
+
## Evidence
|
|
376
|
+
|
|
377
|
+
Reads through `session(traced=True)` go over MCP inside one managed interaction —
|
|
378
|
+
opened on the first read, reused, finished on exit — and come back with a
|
|
379
|
+
receipt: its status, evidence durability, the evidence refs, and the business
|
|
380
|
+
refs resolved down to property granularity. That is the slim receipt a
|
|
381
|
+
completed call carries (foundry #1417); Core keeps the full record, read with
|
|
382
|
+
`openbkn trace interactions operations <interaction-id>`. A receipt whose status
|
|
383
|
+
is `failed` raises `ToolError("receipt_failed")`; a `pending` one comes back
|
|
384
|
+
with no value.
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
with bkn_osdk.session(traced=True):
|
|
388
|
+
page = Order.objects().page(limit=10)
|
|
389
|
+
page.receipt["receipt_status"]
|
|
390
|
+
page.rows[0].__receipt__ # the same receipt, on each row it accounts for
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`order_by` travels to the tool as `sort`; opaque `cursor` continuation also
|
|
394
|
+
stays on MCP and keeps receipts. The tool takes no `need_total` and none of
|
|
395
|
+
the REST query flags (`options(...)`), so a query
|
|
396
|
+
wanting one of those takes the REST path even inside a traced scope — carrying
|
|
397
|
+
the scope's turn, so it is still recorded, but answering without an in-band
|
|
398
|
+
receipt. Untraced reads take REST throughout, which is faster, and carry no
|
|
399
|
+
receipt.
|
|
400
|
+
|
|
401
|
+
Which calls need a turn is a matter of surface, not of tool. The capability
|
|
402
|
+
surface — the MCP tools and their REST twins under `/kn/` — refuses a
|
|
403
|
+
context-free call: every tool in the catalog bar the two lifecycle ones declares
|
|
404
|
+
`bkn_context` required. So `search`, `search_instances` and any direct
|
|
405
|
+
`call_tool` carry a turn on the first attempt, joining the scope's own where
|
|
406
|
+
there is one and opening a short-lived turn where there is not. The instance
|
|
407
|
+
and subgraph read routes under `ontology-query`, and an untraced metric read,
|
|
408
|
+
serve a bare request and mint nothing. A metric read that already has a turn
|
|
409
|
+
deliberately uses Context Loader's managed tool; it never retries a strict
|
|
410
|
+
ontology-query body with `bkn_context` added.
|
|
411
|
+
|
|
412
|
+
A caller that already has a turn passes it in without a traced scope, which is
|
|
413
|
+
how the sandbox does it — `BKN_CONVERSATION_ID` and `BKN_INTERACTION_ID` in the
|
|
414
|
+
environment, inherited with no argument passing, and never finished by this SDK
|
|
415
|
+
because it does not own them.
|
|
416
|
+
|
|
417
|
+
## Search
|
|
418
|
+
|
|
419
|
+
Search is network-level — its request has no object-type dimension — so it lives
|
|
420
|
+
on the package. It calls the `search_schema` MCP tool, the same one the
|
|
421
|
+
TypeScript SDK calls, and answers with the object, relation, action and metric
|
|
422
|
+
types a question touches:
|
|
423
|
+
|
|
424
|
+
```python
|
|
425
|
+
import bkn
|
|
426
|
+
|
|
427
|
+
bkn.search("who owns supply chain")
|
|
428
|
+
bkn.search("orders and their buyers", max_concepts=3, search_scope={"include_action_types": False})
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`search_instances` asks the other question — not which types a question touches
|
|
432
|
+
but which rows answer it — over the `search_instance` tool. Recall runs two
|
|
433
|
+
channels, vector and full text, so only properties whose `condition_operations`
|
|
434
|
+
include `match` or `knn` take part and a type with no index contributes nothing:
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
bkn.search_instances("Lionel Messi")
|
|
438
|
+
bkn.search_instances("欠款最多的客户", object_types=["customer"], rerank=True)
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
It is where to start when neither the type name nor the field name is known.
|
|
442
|
+
Once both are, a typed query is cheaper and exact.
|
|
443
|
+
|
|
444
|
+
## The platform layer
|
|
445
|
+
|
|
446
|
+
Everything the generated classes do not cover — every other network, every
|
|
447
|
+
capability, every route — is reached here:
|
|
448
|
+
|
|
449
|
+
```python
|
|
450
|
+
bkn_osdk.call("/api/agent-observability/v1/traces", query={"limit": 10}) # REST, by path
|
|
451
|
+
bkn_osdk.call("/api/safe/v1/me/api-keys") # anything else
|
|
452
|
+
|
|
453
|
+
ctx = bkn_osdk.resolve_context()
|
|
454
|
+
bkn_osdk.tool_catalog(ctx) # what this deploy publishes
|
|
455
|
+
bkn_osdk.call_tool(ctx, kn_id, "run_sql", {"kn_id": kn_id, "sql": …, "bkn_context": …})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`call_tool` is the raw seam: it takes the arguments the catalog declares and
|
|
459
|
+
returns what the tool answered, with no shape of its own — including the
|
|
460
|
+
envelope, which differs by build. Passing a `bkn_context` is the caller's job
|
|
461
|
+
there, and `ensure_interaction` is how to get one:
|
|
462
|
+
|
|
463
|
+
```python
|
|
464
|
+
from bkn_osdk.lifecycle import ensure_interaction
|
|
465
|
+
|
|
466
|
+
with ensure_interaction(ctx, kn_id) as turn:
|
|
467
|
+
arguments = {"kn_id": kn_id, "bkn_context": turn.bkn_context}
|
|
468
|
+
bkn_osdk.call_tool(ctx, kn_id, "list_skills", arguments)
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Who is calling
|
|
472
|
+
|
|
473
|
+
`me()` returns the record of the user the current credential belongs to, read
|
|
474
|
+
live from bkn-safe:
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
user = bkn_osdk.me()
|
|
478
|
+
user.id, user.account, user.name, user.email, user.telephone
|
|
479
|
+
user.departments, user.roles # department ids; role names
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Inside a Function the credential is the invoking caller's own, so `me()` names
|
|
483
|
+
whoever ran it — for a Function behind an action, the person who triggered the
|
|
484
|
+
action. It is a REST call through `BKN_BASE_URL`. If that is empty in the
|
|
485
|
+
sandbox, it raises `InputError` ("No base URL"), like every other OSDK call;
|
|
486
|
+
if it is set but the sandbox network does not allow the gateway, it fails with
|
|
487
|
+
a connection error.
|
|
488
|
+
|
|
489
|
+
### Named functions for the capability routes
|
|
490
|
+
|
|
491
|
+
The context-loader surface — 25 routes under `/api/agent-retrieval/v1/kn/` — is
|
|
492
|
+
also generated, from foundry's own OpenAPI:
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
from bkn_osdk import kn
|
|
496
|
+
|
|
497
|
+
kn.list_resources(KN_ID)
|
|
498
|
+
kn.run_sql(KN_ID, "SELECT COUNT(*) AS n FROM {{d9hff…}}")
|
|
499
|
+
kn.run_cypher(
|
|
500
|
+
KN_ID,
|
|
501
|
+
"MATCH (o:order)-[:rel_order_customer]->(c:customer) RETURN c.city AS city, count(*) AS n",
|
|
502
|
+
)
|
|
503
|
+
kn.query_object_instance(KN_ID, "order", limit=10, response_format="json")
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Three things the wrapper knows and a raw call does not: **which arguments go in
|
|
507
|
+
the query string** rather than the body (`query_object_instance` and both
|
|
508
|
+
subgraph routes split them, and sending `kn_id` in the body answers
|
|
509
|
+
`Public.NotFound` — "对象不存在" — which reads as a missing object type); which
|
|
510
|
+
arguments are required; and that every route on this surface needs a turn, so
|
|
511
|
+
`bkn_context` is attached rather than being a parameter. `kn_id` is the first
|
|
512
|
+
argument everywhere, because the turn belongs to a network even where the route
|
|
513
|
+
does not send one.
|
|
514
|
+
|
|
515
|
+
The spec is frozen in `contracts/kn-rest.json` and regenerated by
|
|
516
|
+
`scripts/capture_kn_contract.py`; `bkn_osdk/kn.py` is committed, and a test
|
|
517
|
+
fails if regenerating would change it. `call_tool` remains for the tools with no
|
|
518
|
+
REST twin, and for arguments a route accepts but does not publish.
|
|
519
|
+
|
|
520
|
+
`search` and `search_instances` are the same thing wrapped by hand.
|
|
521
|
+
[`examples/platform/`](examples/platform) runs the whole surface.
|
|
522
|
+
|
|
523
|
+
## Examples
|
|
524
|
+
|
|
525
|
+
Six runnable scripts under [`examples/`](examples): [`credentials.py`](examples/credentials.py)
|
|
526
|
+
(which platform, as whom, and where that was decided), three under `ontology/`
|
|
527
|
+
(explore, query, evidence) and two under `platform/` (the tool surface, the
|
|
528
|
+
sandbox).
|
|
529
|
+
|
|
530
|
+
Instance and subgraph query options use `ignore_local_index=True` to bypass a
|
|
531
|
+
Table resource local index and read its original source. The default remains
|
|
532
|
+
`False`; full-text and vector conditions require the index and return 400 when
|
|
533
|
+
bypassed. `Page.query_source` reports `"local_index"`, `"source"`, or `None` if no
|
|
534
|
+
resource was queried. It replaces `Page.search_from_index`; the old
|
|
535
|
+
`ignoring_store_cache` option is removed. Upgrade with the matching Foundry #844
|
|
536
|
+
server change and restart cursors created before the upgrade.
|
|
537
|
+
|
|
538
|
+
The generated `kn.query_object_instance(...)` also replaces `search_after` with
|
|
539
|
+
`cursor`: pass the previous response's opaque `cursor` string unchanged for the
|
|
540
|
+
next page. On the first page, omit `cursor` or pass `cursor=None`. Remove any
|
|
541
|
+
`search_after` argument, including `search_after=None`; that keyword is no longer
|
|
542
|
+
accepted. `cursor` and `offset` are mutually exclusive.
|
|
543
|
+
|
|
544
|
+
## Upgrades
|
|
545
|
+
|
|
546
|
+
| What moves | You run | Guard |
|
|
547
|
+
| --- | --- | --- |
|
|
548
|
+
| runtime | `pip install -U bkn-osdk` | `REQUIRES_RUNTIME`, checked at import |
|
|
549
|
+
| KN schema | `bkn-osdk generate` again | `SCHEMA_FINGERPRINT` + `bkn-osdk check` |
|
|
550
|
+
| emitted code shape | regenerate | `FORMAT_VERSION` |
|
|
551
|
+
|
|
552
|
+
A runtime upgrade never requires regeneration — that is why generated packages
|
|
553
|
+
carry declarations and no logic. `configure(check_schema=True)` adds one
|
|
554
|
+
fingerprint check on the first query, for callers who would rather fail loudly
|
|
555
|
+
than read a stale attribute.
|
|
556
|
+
|
|
557
|
+
## Not in this release
|
|
558
|
+
|
|
559
|
+
Writes and action execution, aggregation over an object set (no endpoint exists —
|
|
560
|
+
see above), typed wrappers for the catalog's MCP-only tools — the lifecycle
|
|
561
|
+
pair and the sandbox tools, reachable today only through `call_tool` — an async
|
|
562
|
+
client, and offline generation from a local
|
|
563
|
+
`.bkn` directory. Each has a section in
|
|
564
|
+
[the design](../docs/superpowers/specs/2026-08-11-python-osdk-design.md)
|
|
565
|
+
describing the shape it takes when it lands.
|
|
566
|
+
|
|
567
|
+
## Development
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
uv venv && uv pip install -e ".[dev]"
|
|
571
|
+
python -m pytest -q # unit tests, all offline
|
|
572
|
+
python -m mypy # strict
|
|
573
|
+
python -m ruff check . && python -m ruff format --check .
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Wire behaviour is pinned by exchanges recorded from a live platform under
|
|
577
|
+
`tests/fixtures/`; re-record with `scripts/capture_schema_fixtures.py` and
|
|
578
|
+
`scripts/capture_query_fixtures.py` when a contract moves.
|
|
579
|
+
|
|
580
|
+
Recorded fixtures cannot notice a route being withdrawn, so there is a live
|
|
581
|
+
suite as well. It generates a package for a real network, type-checks it, reads
|
|
582
|
+
through it and closes a real interaction — and it skips itself unless asked for:
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
BKN_E2E=1 BKN_E2E_KN=ecommerce_ops_bkn_public BKN_BASE_URL=https://your-platform \
|
|
586
|
+
python -m pytest tests/e2e -q
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
`BKN_E2E_OBJECT_TYPE` pins the class under test; without it the suite picks a
|
|
590
|
+
populated type that has relations to walk. Credentials resolve as they do for
|
|
591
|
+
any caller, so `openbkn auth login` is enough. Run it against more than one
|
|
592
|
+
deploy: the two this SDK was built against have disagreed about route names,
|
|
593
|
+
metrics, and which reads their data resources can serve.
|
|
594
|
+
|
|
595
|
+
### Function Trace parent
|
|
596
|
+
|
|
597
|
+
When the sandbox supplies `BKN_PARENT_OPERATION_ID` together with its conversation
|
|
598
|
+
and interaction IDs, internal reads send it as `bkn_context.parent_operation_id`.
|
|
599
|
+
Each read keeps its own operation and receipt. An explicit override to a different
|
|
600
|
+
turn does not inherit the sandbox's parent. Business functions need no new argument.
|