visionai-sdk-python 2.2.0.dev2__tar.gz → 2.2.0.dev3__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.
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/PKG-INFO +21 -9
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/README.md +20 -8
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_base.py +35 -68
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/async_client.py +30 -41
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/_mixin.py +1 -1
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/async_resource.py +15 -13
- visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/auth/credentials.py +292 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/resource.py +17 -13
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/client.py +30 -39
- visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/exceptions.py +97 -0
- visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/relay/__init__.py +67 -0
- visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/relay/protocol.py +178 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/async_resource.py +4 -2
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/resource.py +4 -2
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/PKG-INFO +21 -9
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/SOURCES.txt +13 -1
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/scm_file_list.json +12 -0
- visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/__init__.py +0 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/error_connection.json +3 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/error_request.json +4 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/heartbeat.json +1 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/register.json +8 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/registered.json +4 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/request.json +17 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/response.json +9 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/response_text.json +5 -0
- visionai_sdk_python-2.2.0.dev3/tests/relay/test_protocol.py +176 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_async_client.py +4 -5
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_client.py +4 -5
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_credentials.py +136 -29
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_request.py +109 -20
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/uv.lock +489 -349
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python/auth/credentials.py +0 -234
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python/exceptions.py +0 -47
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/.github/dependabot.yml +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/.github/workflows/ci.yml +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/.github/workflows/publish.yml +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/.gitignore +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/pyproject.toml +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/setup.cfg +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_request_attribution.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_source_header.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/models.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/constants.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/endpoints.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/instrumentation.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/py.typed +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/models.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/constants.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test__base.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_instrumentation.py +0 -0
- {visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/tests/test_vlm_preprocess.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: visionai-sdk-python
|
|
3
|
-
Version: 2.2.0.
|
|
3
|
+
Version: 2.2.0.dev3
|
|
4
4
|
Summary: VisionAI SDK for Python
|
|
5
5
|
Author-email: Tony Yang <tonyyang@linkervision.com>
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -135,8 +135,8 @@ token = client.auth.login("user@example.com", "password")
|
|
|
135
135
|
### OAuth Client Credentials
|
|
136
136
|
|
|
137
137
|
A `ClientCredentials` (or `AsyncClientCredentials`) holds the token for one Platform
|
|
138
|
-
client: it is minted on first use, cached until
|
|
139
|
-
|
|
138
|
+
client: it is minted on first use, cached until shortly before it expires, and one
|
|
139
|
+
instance can serve any number of clients and your own requests.
|
|
140
140
|
|
|
141
141
|
```python
|
|
142
142
|
from visionai_sdk_python import Client, ClientCredentials
|
|
@@ -152,14 +152,26 @@ token = cred.get_token().token # for a WebSocket upgrade or another lib
|
|
|
152
152
|
nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
Platform
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
|
|
156
|
+
concurrent callers share one mint, a refresh that fails keeps the current token while
|
|
157
|
+
Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
|
|
158
|
+
replacement (`cred.invalidate(token)`; a younger one is kept, so a persistent 401
|
|
159
|
+
mints at most twice an hour), and after a 429 no mint is tried for an hour
|
|
160
|
+
(`RateLimitedError`). `get_token(force_refresh=True)` mints on demand.
|
|
160
161
|
|
|
161
162
|
`client.auth.get_access_token(client_id, client_secret)` still works and now builds a
|
|
162
|
-
`ClientCredentials` underneath
|
|
163
|
+
`ClientCredentials` underneath; it returns the cached token unless `force_refresh=True`.
|
|
164
|
+
A later `login()` or `set_token()` replaces the credential: the last explicit
|
|
165
|
+
authentication wins. `vlm_url` is optional for clients that only authenticate.
|
|
166
|
+
|
|
167
|
+
## Relay protocol (internal)
|
|
168
|
+
|
|
169
|
+
`visionai_sdk_python.relay` holds the wire format between platform-relay and
|
|
170
|
+
platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
|
|
171
|
+
`parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
|
|
172
|
+
`docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
|
|
173
|
+
copy, and the ADR's JSON examples are this package's tests. It is internal to the
|
|
174
|
+
platform and not a public API.
|
|
163
175
|
|
|
164
176
|
## VLM Inference
|
|
165
177
|
|
|
@@ -117,8 +117,8 @@ token = client.auth.login("user@example.com", "password")
|
|
|
117
117
|
### OAuth Client Credentials
|
|
118
118
|
|
|
119
119
|
A `ClientCredentials` (or `AsyncClientCredentials`) holds the token for one Platform
|
|
120
|
-
client: it is minted on first use, cached until
|
|
121
|
-
|
|
120
|
+
client: it is minted on first use, cached until shortly before it expires, and one
|
|
121
|
+
instance can serve any number of clients and your own requests.
|
|
122
122
|
|
|
123
123
|
```python
|
|
124
124
|
from visionai_sdk_python import Client, ClientCredentials
|
|
@@ -134,14 +134,26 @@ token = cred.get_token().token # for a WebSocket upgrade or another lib
|
|
|
134
134
|
nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Platform
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
|
|
138
|
+
concurrent callers share one mint, a refresh that fails keeps the current token while
|
|
139
|
+
Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
|
|
140
|
+
replacement (`cred.invalidate(token)`; a younger one is kept, so a persistent 401
|
|
141
|
+
mints at most twice an hour), and after a 429 no mint is tried for an hour
|
|
142
|
+
(`RateLimitedError`). `get_token(force_refresh=True)` mints on demand.
|
|
142
143
|
|
|
143
144
|
`client.auth.get_access_token(client_id, client_secret)` still works and now builds a
|
|
144
|
-
`ClientCredentials` underneath
|
|
145
|
+
`ClientCredentials` underneath; it returns the cached token unless `force_refresh=True`.
|
|
146
|
+
A later `login()` or `set_token()` replaces the credential: the last explicit
|
|
147
|
+
authentication wins. `vlm_url` is optional for clients that only authenticate.
|
|
148
|
+
|
|
149
|
+
## Relay protocol (internal)
|
|
150
|
+
|
|
151
|
+
`visionai_sdk_python.relay` holds the wire format between platform-relay and
|
|
152
|
+
platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
|
|
153
|
+
`parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
|
|
154
|
+
`docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
|
|
155
|
+
copy, and the ADR's JSON examples are this package's tests. It is internal to the
|
|
156
|
+
platform and not a public API.
|
|
145
157
|
|
|
146
158
|
## VLM Inference
|
|
147
159
|
|
{visionai_sdk_python-2.2.0.dev2 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_base.py
RENAMED
|
@@ -5,16 +5,7 @@ import httpx
|
|
|
5
5
|
|
|
6
6
|
from ._jwt_verifier import JwtVerifier
|
|
7
7
|
from .constants import resolve_allowed_issuers
|
|
8
|
-
from .exceptions import
|
|
9
|
-
APIError,
|
|
10
|
-
AuthenticationError,
|
|
11
|
-
ClientError,
|
|
12
|
-
NetworkError,
|
|
13
|
-
PermissionDeniedError,
|
|
14
|
-
RateLimitedError,
|
|
15
|
-
ServerError,
|
|
16
|
-
VisionaiSDKError,
|
|
17
|
-
)
|
|
8
|
+
from .exceptions import raise_for_status
|
|
18
9
|
|
|
19
10
|
if TYPE_CHECKING:
|
|
20
11
|
from .auth.credentials import AsyncClientCredentials, ClientCredentials
|
|
@@ -53,8 +44,9 @@ class _BaseClient:
|
|
|
53
44
|
max_keepalive_connections: Maximum number of idle keep-alive connections.
|
|
54
45
|
credential: A ``ClientCredentials`` / ``AsyncClientCredentials`` whose
|
|
55
46
|
token every call uses; shareable between clients. Without it the
|
|
56
|
-
client keeps the token from ``auth.login()``
|
|
57
|
-
|
|
47
|
+
client keeps the token from ``auth.login()`` or ``set_token()``;
|
|
48
|
+
``auth.get_access_token()`` installs a credential. The last explicit
|
|
49
|
+
authentication wins.
|
|
58
50
|
"""
|
|
59
51
|
if not auth_url.strip():
|
|
60
52
|
raise ValueError("auth_url must not be empty")
|
|
@@ -83,7 +75,7 @@ class _BaseClient:
|
|
|
83
75
|
self._access_token: str | None = None
|
|
84
76
|
self._token_expires_at: float | None = None # monotonic time
|
|
85
77
|
self._credentials: dict | None = None
|
|
86
|
-
self._credentials_type: Literal["login"
|
|
78
|
+
self._credentials_type: Literal["login"] | None = None
|
|
87
79
|
|
|
88
80
|
@property
|
|
89
81
|
def credential(self) -> "ClientCredentials | AsyncClientCredentials | None":
|
|
@@ -96,32 +88,36 @@ class _BaseClient:
|
|
|
96
88
|
"""Join a base URL and a path, normalizing slashes."""
|
|
97
89
|
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
98
90
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _full_url(url: str) -> str:
|
|
93
|
+
"""``request()`` takes a full URL: the client has two bases (``auth_url``,
|
|
94
|
+
``vlm_url``), so a bare path would not say which one is meant."""
|
|
95
|
+
if "://" not in url:
|
|
96
|
+
raise ValueError(f"request() needs a full URL, got {url!r}")
|
|
97
|
+
return url
|
|
102
98
|
|
|
103
99
|
def _with_auth(self, kwargs: dict, token: str) -> dict:
|
|
104
100
|
"""``kwargs`` for httpx with the bearer header; the client's token wins."""
|
|
105
|
-
headers =
|
|
106
|
-
|
|
107
|
-
**self._build_auth_header(token),
|
|
108
|
-
}
|
|
101
|
+
headers = httpx.Headers(kwargs.get("headers"))
|
|
102
|
+
headers["Authorization"] = f"Bearer {token}" # case-insensitive: one header
|
|
109
103
|
return {**kwargs, "headers": headers}
|
|
110
104
|
|
|
111
|
-
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _resendable(kwargs: dict) -> bool:
|
|
107
|
+
"""Whether the body can be sent a second time (streams and files cannot)."""
|
|
108
|
+
content = kwargs.get("content")
|
|
109
|
+
if content is not None and not isinstance(content, (bytes, str)):
|
|
110
|
+
return False
|
|
111
|
+
if kwargs.get("files") is not None:
|
|
112
|
+
return False
|
|
113
|
+
data = kwargs.get("data")
|
|
114
|
+
return data is None or isinstance(data, dict)
|
|
115
|
+
|
|
116
|
+
def _require_vlm_url(self) -> str:
|
|
112
117
|
if self.vlm_url is None:
|
|
113
118
|
raise ValueError("vlm_url is required for VLM calls; pass it to the client")
|
|
114
119
|
return self.vlm_url
|
|
115
120
|
|
|
116
|
-
@staticmethod
|
|
117
|
-
def _map_transport_error(error: httpx.RequestError) -> VisionaiSDKError:
|
|
118
|
-
"""The SDK exception for an httpx request that never got a response."""
|
|
119
|
-
if isinstance(error, httpx.TimeoutException):
|
|
120
|
-
return NetworkError("Request timed out")
|
|
121
|
-
if isinstance(error, httpx.NetworkError):
|
|
122
|
-
return NetworkError(f"Network error: {error}")
|
|
123
|
-
return VisionaiSDKError(f"Request failed: {error}")
|
|
124
|
-
|
|
125
121
|
@staticmethod
|
|
126
122
|
def _build_auth_header(access_token: str) -> dict[str, str]:
|
|
127
123
|
"""Build authorization header."""
|
|
@@ -134,16 +130,20 @@ class _BaseClient:
|
|
|
134
130
|
access_token: str,
|
|
135
131
|
expires_in: int,
|
|
136
132
|
credentials: dict | None = None,
|
|
137
|
-
credentials_type: Literal["login"
|
|
133
|
+
credentials_type: Literal["login"] | None = None,
|
|
138
134
|
) -> None:
|
|
139
|
-
"""
|
|
135
|
+
"""Use this token from now on; a credential set earlier is dropped.
|
|
136
|
+
|
|
137
|
+
The last explicit authentication wins, in both directions.
|
|
140
138
|
|
|
141
139
|
Args:
|
|
142
140
|
access_token: JWT access token.
|
|
143
141
|
expires_in: Token expiration time in seconds.
|
|
144
142
|
credentials: Optional credentials for auto-refresh.
|
|
145
|
-
credentials_type:
|
|
143
|
+
credentials_type: "login" for email / password credentials; client
|
|
144
|
+
credentials live in a ``ClientCredentials`` on the client instead.
|
|
146
145
|
"""
|
|
146
|
+
self._credential = None
|
|
147
147
|
self._access_token = access_token
|
|
148
148
|
self._token_expires_at = time.monotonic() + expires_in
|
|
149
149
|
self._credentials = credentials
|
|
@@ -219,38 +219,5 @@ class _BaseClient:
|
|
|
219
219
|
|
|
220
220
|
@staticmethod
|
|
221
221
|
def _handle_response(response: httpx.Response) -> httpx.Response:
|
|
222
|
-
"""Raise SDK
|
|
223
|
-
|
|
224
|
-
Raises:
|
|
225
|
-
AuthenticationError: If the server returns 401
|
|
226
|
-
PermissionDeniedError: If the server returns 403
|
|
227
|
-
ClientError: If the server returns any other 4xx
|
|
228
|
-
ServerError: If the server returns 5xx
|
|
229
|
-
APIError: If the server returns any other non-2xx status
|
|
230
|
-
"""
|
|
231
|
-
try:
|
|
232
|
-
response.raise_for_status()
|
|
233
|
-
except httpx.HTTPStatusError as e:
|
|
234
|
-
body = None
|
|
235
|
-
if e.response.content:
|
|
236
|
-
try:
|
|
237
|
-
body = e.response.json()
|
|
238
|
-
except Exception:
|
|
239
|
-
pass
|
|
240
|
-
if isinstance(body, dict):
|
|
241
|
-
detail: str = body.get("detail") or body.get("message") or str(e)
|
|
242
|
-
else:
|
|
243
|
-
detail = str(e)
|
|
244
|
-
status = e.response.status_code
|
|
245
|
-
if status == 401:
|
|
246
|
-
raise AuthenticationError(detail) from e
|
|
247
|
-
if status == 403:
|
|
248
|
-
raise PermissionDeniedError(detail) from e
|
|
249
|
-
if status == 429:
|
|
250
|
-
raise RateLimitedError(detail) from e
|
|
251
|
-
if 400 <= status < 500:
|
|
252
|
-
raise ClientError(status, detail) from e
|
|
253
|
-
if 500 <= status < 600:
|
|
254
|
-
raise ServerError(status, detail) from e
|
|
255
|
-
raise APIError(status, detail) from e
|
|
256
|
-
return response
|
|
222
|
+
"""Raise the SDK exception for a non-2xx response (``exceptions.raise_for_status``)."""
|
|
223
|
+
return raise_for_status(response)
|
|
@@ -4,7 +4,7 @@ from ._base import _BaseClient
|
|
|
4
4
|
from ._request_attribution import merge_request_attribution
|
|
5
5
|
from .auth.async_resource import AsyncAuthResource
|
|
6
6
|
from .auth.credentials import AsyncClientCredentials
|
|
7
|
-
from .exceptions import AuthenticationError
|
|
7
|
+
from .exceptions import AuthenticationError, from_transport_error
|
|
8
8
|
from .vlm.async_resource import AsyncVLMResource
|
|
9
9
|
|
|
10
10
|
|
|
@@ -41,7 +41,7 @@ class AsyncClient(_BaseClient):
|
|
|
41
41
|
max_keepalive_connections=self.max_keepalive_connections,
|
|
42
42
|
),
|
|
43
43
|
)
|
|
44
|
-
self.
|
|
44
|
+
self._http_owner = http_client is None # a client we built is ours to close
|
|
45
45
|
# Register the different function resource
|
|
46
46
|
self.auth = AsyncAuthResource(self)
|
|
47
47
|
self.vlm = AsyncVLMResource(self)
|
|
@@ -52,48 +52,41 @@ class AsyncClient(_BaseClient):
|
|
|
52
52
|
try:
|
|
53
53
|
response = await self._client.request(method, url, **kwargs)
|
|
54
54
|
except httpx.RequestError as e:
|
|
55
|
-
raise
|
|
55
|
+
raise from_transport_error(e) from e
|
|
56
56
|
return self._handle_response(response)
|
|
57
57
|
|
|
58
58
|
async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
59
59
|
"""Send any request with this client's bearer token.
|
|
60
60
|
|
|
61
|
-
``url`` is
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
|
|
62
|
+
With a ``credential``, a 401 buys one retry with the credential's current
|
|
63
|
+
token, which it replaces when the rejected one is old enough to have been
|
|
64
|
+
revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
|
|
65
|
+
credential, or with a body that cannot be sent twice (a stream or a
|
|
66
|
+
file), a 401 raises.
|
|
64
67
|
|
|
65
68
|
Raises:
|
|
69
|
+
ValueError: ``url`` is not a full URL.
|
|
66
70
|
AuthenticationError: No token, or the fresh one was refused too.
|
|
67
71
|
RateLimitedError, NetworkError, ServerError, ClientError: As the
|
|
68
72
|
resource methods.
|
|
69
73
|
"""
|
|
70
|
-
url = self.
|
|
71
|
-
await self._ensure_token()
|
|
72
|
-
token = self._access_token
|
|
74
|
+
url = self._full_url(url)
|
|
75
|
+
token = await self._ensure_token()
|
|
73
76
|
try:
|
|
74
77
|
return await self._request(method, url, **self._with_auth(kwargs, token))
|
|
75
78
|
except AuthenticationError:
|
|
76
|
-
if not
|
|
79
|
+
if self._credential is None or not self._resendable(kwargs):
|
|
77
80
|
raise
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"""A 401 for ``token``: get a different one, once; False when none would help."""
|
|
84
|
-
if self._credential is not None:
|
|
85
|
-
if not self._credential.invalidate(token):
|
|
86
|
-
return False
|
|
87
|
-
self._access_token = (await self._credential.get_token()).token
|
|
88
|
-
return True
|
|
89
|
-
if self._credentials is None or self._access_token != token:
|
|
90
|
-
return False
|
|
91
|
-
await self._refresh_token()
|
|
92
|
-
return self._access_token != token
|
|
81
|
+
self._credential.invalidate(token)
|
|
82
|
+
replacement = (await self._credential.get_token()).token
|
|
83
|
+
if replacement == token:
|
|
84
|
+
raise
|
|
85
|
+
return await self._request(method, url, **self._with_auth(kwargs, replacement))
|
|
93
86
|
|
|
94
87
|
async def close(self) -> None:
|
|
95
88
|
"""Close the HTTP client this client created and release its connections."""
|
|
96
|
-
if self.
|
|
89
|
+
if self._http_owner:
|
|
97
90
|
await self._client.aclose()
|
|
98
91
|
|
|
99
92
|
async def __aenter__(self) -> "AsyncClient":
|
|
@@ -105,7 +98,9 @@ class AsyncClient(_BaseClient):
|
|
|
105
98
|
await self.close()
|
|
106
99
|
|
|
107
100
|
async def _refresh_token(self) -> None:
|
|
108
|
-
"""Refresh token
|
|
101
|
+
"""Refresh the token with the stored login credentials.
|
|
102
|
+
|
|
103
|
+
Client credentials refresh through the credential object instead.
|
|
109
104
|
|
|
110
105
|
Raises:
|
|
111
106
|
AuthenticationError: If no credentials are stored or refresh fails.
|
|
@@ -113,26 +108,19 @@ class AsyncClient(_BaseClient):
|
|
|
113
108
|
if self._credentials is None or self._credentials_type is None:
|
|
114
109
|
raise AuthenticationError("No credentials available for token refresh")
|
|
115
110
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
elif self._credentials_type == "client":
|
|
122
|
-
await self.auth.get_access_token(
|
|
123
|
-
client_id=self._credentials["client_id"],
|
|
124
|
-
client_secret=self._credentials["client_secret"],
|
|
125
|
-
)
|
|
111
|
+
await self.auth.login(
|
|
112
|
+
email=self._credentials["email"],
|
|
113
|
+
password=self._credentials["password"],
|
|
114
|
+
)
|
|
126
115
|
|
|
127
|
-
async def _ensure_token(self) ->
|
|
128
|
-
"""
|
|
116
|
+
async def _ensure_token(self) -> str:
|
|
117
|
+
"""The token to send: the credential's, else the stored one, refreshed if expiring.
|
|
129
118
|
|
|
130
119
|
Raises:
|
|
131
120
|
AuthenticationError: If no token is available or token expired without credentials.
|
|
132
121
|
"""
|
|
133
122
|
if self._credential is not None:
|
|
134
|
-
|
|
135
|
-
return
|
|
123
|
+
return (await self._credential.get_token()).token
|
|
136
124
|
if self._access_token is None:
|
|
137
125
|
raise AuthenticationError(
|
|
138
126
|
"Not authenticated. Call login() or get_access_token() first."
|
|
@@ -144,3 +132,4 @@ class AsyncClient(_BaseClient):
|
|
|
144
132
|
"Token expired and no credentials available for refresh"
|
|
145
133
|
)
|
|
146
134
|
await self._refresh_token()
|
|
135
|
+
return self._access_token
|
|
@@ -97,7 +97,7 @@ class AuthMixin:
|
|
|
97
97
|
Args:
|
|
98
98
|
response_data: Response JSON data
|
|
99
99
|
credentials: Credentials dict for auto-refresh
|
|
100
|
-
credentials_type: Type of credentials ("login"
|
|
100
|
+
credentials_type: Type of credentials ("login")
|
|
101
101
|
|
|
102
102
|
Returns:
|
|
103
103
|
Parsed TokenResponse
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Authentication resource for async operations."""
|
|
2
2
|
|
|
3
|
-
import time
|
|
4
3
|
from typing import TYPE_CHECKING
|
|
5
4
|
|
|
6
5
|
import jwt
|
|
@@ -29,13 +28,16 @@ class AsyncAuthResource(AuthMixin):
|
|
|
29
28
|
self._sdk_client = client
|
|
30
29
|
|
|
31
30
|
async def get_access_token(
|
|
32
|
-
self, client_id: str, client_secret: str
|
|
31
|
+
self, client_id: str, client_secret: str, *, force_refresh: bool = False
|
|
33
32
|
) -> TokenResponse:
|
|
34
33
|
"""Get access token using client credentials flow.
|
|
35
34
|
|
|
36
|
-
The
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
The client keeps the resulting credential and uses it for subsequent API
|
|
36
|
+
calls, minting a new token when the current one expires. Calling this
|
|
37
|
+
again with the same client credentials returns the cached token;
|
|
38
|
+
``force_refresh=True`` mints a new one. A later ``login()`` or
|
|
39
|
+
``set_token()`` replaces the credential: the last explicit
|
|
40
|
+
authentication wins.
|
|
39
41
|
|
|
40
42
|
New code should build a ``ClientCredentials`` / ``AsyncClientCredentials``
|
|
41
43
|
and pass it as ``credential=`` to the client instead; this method stays for
|
|
@@ -44,6 +46,7 @@ class AsyncAuthResource(AuthMixin):
|
|
|
44
46
|
Args:
|
|
45
47
|
client_id: OAuth client ID
|
|
46
48
|
client_secret: OAuth client secret
|
|
49
|
+
force_refresh: Mint a new token even if a fresh one is cached.
|
|
47
50
|
|
|
48
51
|
Returns:
|
|
49
52
|
TokenResponse with access_token, expires_in, and token_type
|
|
@@ -70,16 +73,15 @@ class AsyncAuthResource(AuthMixin):
|
|
|
70
73
|
client_secret,
|
|
71
74
|
http=self._sdk_client._client,
|
|
72
75
|
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
token = await credential.get_token(force_refresh=force_refresh)
|
|
77
|
+
# Installed only once it works, and over a login() / set_token() token:
|
|
78
|
+
# the last explicit authentication wins.
|
|
79
|
+
self._sdk_client._credential = credential
|
|
80
|
+
return TokenResponse(
|
|
76
81
|
access_token=token.token,
|
|
77
|
-
expires_in=
|
|
78
|
-
|
|
79
|
-
credentials_type="client",
|
|
82
|
+
expires_in=token.expires_in(),
|
|
83
|
+
token_type=token.token_type,
|
|
80
84
|
)
|
|
81
|
-
assert credential.last_response is not None
|
|
82
|
-
return credential.last_response
|
|
83
85
|
|
|
84
86
|
async def login(self, email: str, password: str) -> TokenResponse:
|
|
85
87
|
"""Login with email and password to get JWT token.
|