bkn-osdk 0.2.1__py3-none-any.whl
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/__init__.py +122 -0
- bkn_osdk/auth.py +199 -0
- bkn_osdk/codegen/__init__.py +11 -0
- bkn_osdk/codegen/cli.py +375 -0
- bkn_osdk/codegen/diff.py +110 -0
- bkn_osdk/codegen/emit.py +619 -0
- bkn_osdk/codegen/kn_rest.py +233 -0
- bkn_osdk/config.py +522 -0
- bkn_osdk/errors.py +207 -0
- bkn_osdk/http.py +245 -0
- bkn_osdk/identity.py +88 -0
- bkn_osdk/kn.py +1411 -0
- bkn_osdk/lifecycle.py +354 -0
- bkn_osdk/mcp.py +393 -0
- bkn_osdk/meta.py +181 -0
- bkn_osdk/metrics.py +234 -0
- bkn_osdk/oauth.py +701 -0
- bkn_osdk/py.typed +0 -0
- bkn_osdk/query.py +659 -0
- bkn_osdk/schema.py +393 -0
- bkn_osdk/search.py +137 -0
- bkn_osdk/subgraph.py +251 -0
- bkn_osdk/types.py +456 -0
- bkn_osdk-0.2.1.dist-info/METADATA +600 -0
- bkn_osdk-0.2.1.dist-info/RECORD +27 -0
- bkn_osdk-0.2.1.dist-info/WHEEL +4 -0
- bkn_osdk-0.2.1.dist-info/entry_points.txt +2 -0
bkn_osdk/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Copyright (c) 2026 OpenBKN. All rights reserved.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See the LICENSE file in the project root.
|
|
3
|
+
|
|
4
|
+
"""bkn-osdk — a Python SDK for BKN, in two layers.
|
|
5
|
+
|
|
6
|
+
**The platform layer** addresses the platform itself: any REST route by path
|
|
7
|
+
(`call`, `request`) and any tool the deploy publishes by name (`call_tool`,
|
|
8
|
+
`tool_catalog`). Every network, every capability, no generation — at the cost of
|
|
9
|
+
shaping arguments yourself against what the catalog declares.
|
|
10
|
+
|
|
11
|
+
**The ontology layer** addresses one knowledge network's ontology: classes
|
|
12
|
+
generated from its schema, where `Order.total_amount` is a property this network
|
|
13
|
+
really has and a typo is a type error rather than an empty result set.
|
|
14
|
+
|
|
15
|
+
The layers are named for what they address, not for how they were built. Both
|
|
16
|
+
run through the same credentials, the same transports and the same managed turn,
|
|
17
|
+
so a script can drop to the platform layer for one call and come back up with
|
|
18
|
+
its evidence still on one chain.
|
|
19
|
+
|
|
20
|
+
A generated package imports from here and from nothing else, so a fix or a new
|
|
21
|
+
operator reaches it through `pip install -U bkn-osdk` with no regeneration.
|
|
22
|
+
|
|
23
|
+
Import is side-effect free: nothing resolves credentials or touches the network
|
|
24
|
+
until a call is made.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from importlib.metadata import PackageNotFoundError
|
|
30
|
+
from importlib.metadata import version as _version
|
|
31
|
+
|
|
32
|
+
from . import oauth
|
|
33
|
+
from .config import (
|
|
34
|
+
DEFAULT_TIMEOUT,
|
|
35
|
+
Context,
|
|
36
|
+
configure,
|
|
37
|
+
resolve_context,
|
|
38
|
+
session,
|
|
39
|
+
)
|
|
40
|
+
from .errors import (
|
|
41
|
+
AuthError,
|
|
42
|
+
BknError,
|
|
43
|
+
FormatVersionError,
|
|
44
|
+
HttpError,
|
|
45
|
+
InputError,
|
|
46
|
+
ObjectNotFound,
|
|
47
|
+
PlatformVersionError,
|
|
48
|
+
SchemaDriftError,
|
|
49
|
+
ToolError,
|
|
50
|
+
)
|
|
51
|
+
from .http import call, request
|
|
52
|
+
from .identity import Me, me
|
|
53
|
+
from .mcp import ToolResult, call_tool, tool_catalog
|
|
54
|
+
from .metrics import Metric
|
|
55
|
+
from .query import Comparison, Composite, Filter, ObjectSet, Page, Sort, to_condition
|
|
56
|
+
from .schema import (
|
|
57
|
+
KnSchema,
|
|
58
|
+
MetricDef,
|
|
59
|
+
ObjectTypeDef,
|
|
60
|
+
PropertyDef,
|
|
61
|
+
RelationTypeDef,
|
|
62
|
+
fingerprint,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Rebinds the `search` attribute from the submodule to the function it exports —
|
|
66
|
+
# `bkn_osdk.search(kn_id, query)` is the surface, `bkn_osdk.search` the module is
|
|
67
|
+
# an implementation detail. `import bkn_osdk.search` still resolves the module.
|
|
68
|
+
from .search import search, search_instances
|
|
69
|
+
from .subgraph import RelationPath
|
|
70
|
+
from .types import ObjectType, Property, PropertyRef, Relation
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
__version__ = _version("bkn-osdk")
|
|
74
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
75
|
+
__version__ = "0.0.0.dev0"
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"DEFAULT_TIMEOUT",
|
|
79
|
+
"AuthError",
|
|
80
|
+
"BknError",
|
|
81
|
+
"Comparison",
|
|
82
|
+
"Composite",
|
|
83
|
+
"Context",
|
|
84
|
+
"Filter",
|
|
85
|
+
"FormatVersionError",
|
|
86
|
+
"HttpError",
|
|
87
|
+
"InputError",
|
|
88
|
+
"KnSchema",
|
|
89
|
+
"Me",
|
|
90
|
+
"Metric",
|
|
91
|
+
"MetricDef",
|
|
92
|
+
"ObjectNotFound",
|
|
93
|
+
"ObjectSet",
|
|
94
|
+
"ObjectType",
|
|
95
|
+
"ObjectTypeDef",
|
|
96
|
+
"Page",
|
|
97
|
+
"PlatformVersionError",
|
|
98
|
+
"Property",
|
|
99
|
+
"PropertyDef",
|
|
100
|
+
"PropertyRef",
|
|
101
|
+
"Relation",
|
|
102
|
+
"RelationPath",
|
|
103
|
+
"RelationTypeDef",
|
|
104
|
+
"SchemaDriftError",
|
|
105
|
+
"Sort",
|
|
106
|
+
"ToolError",
|
|
107
|
+
"ToolResult",
|
|
108
|
+
"__version__",
|
|
109
|
+
"call",
|
|
110
|
+
"call_tool",
|
|
111
|
+
"configure",
|
|
112
|
+
"fingerprint",
|
|
113
|
+
"me",
|
|
114
|
+
"oauth",
|
|
115
|
+
"request",
|
|
116
|
+
"resolve_context",
|
|
117
|
+
"search",
|
|
118
|
+
"search_instances",
|
|
119
|
+
"session",
|
|
120
|
+
"to_condition",
|
|
121
|
+
"tool_catalog",
|
|
122
|
+
]
|
bkn_osdk/auth.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Copyright (c) 2026 OpenBKN. All rights reserved.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See the LICENSE file in the project root.
|
|
3
|
+
|
|
4
|
+
"""Keeping a stored session alive for the length of a Python process.
|
|
5
|
+
|
|
6
|
+
Access tokens on this platform are short-lived, so a long-running script that
|
|
7
|
+
resolved its credentials from `~/.bkn` goes stale mid-run and starts getting
|
|
8
|
+
401s. The `openbkn` CLI solves that by refreshing on a 401 and persisting; this
|
|
9
|
+
side does the same exchange, in memory.
|
|
10
|
+
|
|
11
|
+
**The one time this writes to the store** is refresh-token rotation. Where the
|
|
12
|
+
authorization server returns a *new* refresh token, the old one is spent — so
|
|
13
|
+
keeping the new one to ourselves would strand the CLI with a dead credential and
|
|
14
|
+
force a re-login. Writing it back is what preserves the CLI's session, not a
|
|
15
|
+
land-grab on a file it owns; where no new token comes back, nothing is written.
|
|
16
|
+
|
|
17
|
+
Refresh applies only to credentials that came from the store. An explicit
|
|
18
|
+
`token=` or `BKN_TOKEN` is the caller's to manage, and an AppKey has no refresh
|
|
19
|
+
at all — its 401 means re-issue.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import threading
|
|
25
|
+
import urllib.parse
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from . import oauth
|
|
31
|
+
from .config import Context, StoredCredential, TokenHolder, write_refreshed_token
|
|
32
|
+
from .errors import BknError
|
|
33
|
+
|
|
34
|
+
__all__ = ["can_refresh", "refreshed_token", "token_for"]
|
|
35
|
+
|
|
36
|
+
#: An app's token set is renewed this long before it expires, so a request does
|
|
37
|
+
#: not set off with a token that dies in flight.
|
|
38
|
+
REFRESH_WINDOW_SECONDS = 60.0
|
|
39
|
+
|
|
40
|
+
TOKEN_PATH = "/oauth2/token"
|
|
41
|
+
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
#: (base URL, user id) -> the freshest access token this process has obtained.
|
|
44
|
+
_fresh: dict[tuple[str, str], str] = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _Tokens:
|
|
49
|
+
access_token: str
|
|
50
|
+
refresh_token: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def can_refresh(ctx: Context) -> bool:
|
|
54
|
+
"""Whether a 401 for this context is worth a refresh attempt."""
|
|
55
|
+
return ctx.credential is not None or ctx.app_tokens is not None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def token_for(ctx: Context) -> str:
|
|
59
|
+
"""The token to send: whatever this process last refreshed, else the stored one.
|
|
60
|
+
|
|
61
|
+
Without this, every request after the first expiry would pay for its own
|
|
62
|
+
refresh round trip.
|
|
63
|
+
"""
|
|
64
|
+
if ctx.app_tokens is not None:
|
|
65
|
+
return _app_token(ctx, ctx.app_tokens)
|
|
66
|
+
credential = ctx.credential
|
|
67
|
+
if credential is None:
|
|
68
|
+
return ctx.token
|
|
69
|
+
with _lock:
|
|
70
|
+
return _fresh.get((credential.base_url, credential.user_id), ctx.token)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def refreshed_token(ctx: Context, used: str) -> str | None:
|
|
74
|
+
"""Exchange the refresh token for a fresh access token, or None if that fails.
|
|
75
|
+
|
|
76
|
+
`used` is the token that just came back 401; if another thread refreshed in
|
|
77
|
+
the meantime, its result is returned instead of a second exchange.
|
|
78
|
+
"""
|
|
79
|
+
if ctx.app_tokens is not None:
|
|
80
|
+
return _app_refreshed(ctx, ctx.app_tokens, used)
|
|
81
|
+
credential = ctx.credential
|
|
82
|
+
if credential is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
key = (credential.base_url, credential.user_id)
|
|
86
|
+
with _lock:
|
|
87
|
+
current = _fresh.get(key)
|
|
88
|
+
if current is not None and current != used:
|
|
89
|
+
return current
|
|
90
|
+
|
|
91
|
+
tokens = _exchange(credential, ctx.insecure, ctx.timeout)
|
|
92
|
+
if tokens is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
with _lock:
|
|
96
|
+
_fresh[key] = tokens.access_token
|
|
97
|
+
|
|
98
|
+
if tokens.refresh_token and tokens.refresh_token != credential.refresh_token:
|
|
99
|
+
# Rotation: the stored refresh token is now spent. Hand the replacement
|
|
100
|
+
# back to the store, or the next `openbkn` command has no way in.
|
|
101
|
+
write_refreshed_token(
|
|
102
|
+
credential.base_url,
|
|
103
|
+
credential.user_id,
|
|
104
|
+
access_token=tokens.access_token,
|
|
105
|
+
refresh_token=tokens.refresh_token,
|
|
106
|
+
)
|
|
107
|
+
return tokens.access_token
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _app_token(ctx: Context, holder: TokenHolder) -> str:
|
|
111
|
+
"""The holder's access token, renewed first if it is about to expire.
|
|
112
|
+
|
|
113
|
+
The lock is held across the renewal so threads arriving together wait for
|
|
114
|
+
one refresh and then all read its result.
|
|
115
|
+
"""
|
|
116
|
+
with holder.lock:
|
|
117
|
+
if holder.tokens.expires_within(REFRESH_WINDOW_SECONDS):
|
|
118
|
+
renewed = _renew(ctx, holder)
|
|
119
|
+
if renewed is not None:
|
|
120
|
+
return renewed
|
|
121
|
+
return holder.tokens.access_token
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _app_refreshed(ctx: Context, holder: TokenHolder, used: str) -> str | None:
|
|
125
|
+
"""Refresh after a 401 on `used`, unless another thread already replaced it."""
|
|
126
|
+
with holder.lock:
|
|
127
|
+
current = holder.tokens.access_token
|
|
128
|
+
if current != used:
|
|
129
|
+
return current
|
|
130
|
+
return _renew(ctx, holder)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _renew(ctx: Context, holder: TokenHolder) -> str | None:
|
|
134
|
+
"""Run the refresh grant for a holder (lock held); None when it fails.
|
|
135
|
+
|
|
136
|
+
A failed grant is not raised: the caller falls back to the request's own 401,
|
|
137
|
+
which `http.py` annotates with the sign-in-again hint. `on_refresh` runs after
|
|
138
|
+
the holder is updated, and its exceptions are deliberately *not* caught — an
|
|
139
|
+
app that cannot save a rotated refresh token wants to know before it loses it.
|
|
140
|
+
"""
|
|
141
|
+
tokens = holder.tokens
|
|
142
|
+
if not tokens.refresh_token:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
renewed = oauth.refresh(
|
|
146
|
+
ctx.base_url,
|
|
147
|
+
tokens,
|
|
148
|
+
client_id=holder.client_id,
|
|
149
|
+
insecure=ctx.insecure,
|
|
150
|
+
timeout=ctx.timeout,
|
|
151
|
+
)
|
|
152
|
+
except BknError:
|
|
153
|
+
return None
|
|
154
|
+
holder.tokens = renewed
|
|
155
|
+
if holder.on_refresh is not None:
|
|
156
|
+
holder.on_refresh(renewed)
|
|
157
|
+
return renewed.access_token
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _exchange(credential: StoredCredential, insecure: bool, timeout: float) -> _Tokens | None:
|
|
161
|
+
"""The RFC 6749 refresh grant, against a public client with no secret.
|
|
162
|
+
|
|
163
|
+
A failure returns None rather than raising, so the caller surfaces the
|
|
164
|
+
original 401 — which carries the platform's own next-step hint — instead of
|
|
165
|
+
a second error about the refresh.
|
|
166
|
+
"""
|
|
167
|
+
body = urllib.parse.urlencode(
|
|
168
|
+
{
|
|
169
|
+
"grant_type": "refresh_token",
|
|
170
|
+
"refresh_token": credential.refresh_token,
|
|
171
|
+
"client_id": credential.client_id,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
with httpx.Client(verify=not insecure, timeout=timeout) as client:
|
|
176
|
+
response = client.post(
|
|
177
|
+
f"{credential.base_url}{TOKEN_PATH}",
|
|
178
|
+
content=body,
|
|
179
|
+
headers={
|
|
180
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
181
|
+
"accept": "application/json",
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
if response.is_error:
|
|
185
|
+
return None
|
|
186
|
+
payload = response.json()
|
|
187
|
+
except (httpx.HTTPError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
access = payload.get("access_token") if isinstance(payload, dict) else None
|
|
191
|
+
if not isinstance(access, str) or not access:
|
|
192
|
+
return None
|
|
193
|
+
rotated = payload.get("refresh_token") if isinstance(payload, dict) else None
|
|
194
|
+
return _Tokens(access, rotated if isinstance(rotated, str) and rotated else None)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _reset_for_tests() -> None:
|
|
198
|
+
with _lock:
|
|
199
|
+
_fresh.clear()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copyright (c) 2026 OpenBKN. All rights reserved.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See the LICENSE file in the project root.
|
|
3
|
+
|
|
4
|
+
"""The generator: a pure `schema -> file tree` function plus its CLI shell.
|
|
5
|
+
|
|
6
|
+
Ships in the same distribution as the runtime but is imported only by the
|
|
7
|
+
`bkn-osdk` console script, so it costs an installed consumer nothing at runtime.
|
|
8
|
+
|
|
9
|
+
The core must stay pure — no HTTP, no filesystem — so that the CLI shell, the
|
|
10
|
+
test shell, and a future server-side wheel builder can all drive it.
|
|
11
|
+
"""
|