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 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
+ """