Skip to content

Commit 8dc719d

Browse files
Test fixes and readme update
1 parent 61612a8 commit 8dc719d

6 files changed

Lines changed: 728 additions & 523 deletions

File tree

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,102 @@ s = UnstructuredClient(debug_logger=logging.getLogger("unstructured_client"))
502502

503503
<!-- Placeholder for Future Speakeasy SDK Sections -->
504504

505+
## Authentication with Client Secrets
506+
507+
> Available from SDK version **X.Y.Z** (first release carrying the
508+
> `unstructured_client.auth` module).
509+
510+
If you are running against an Unstructured deployment that issues **client
511+
secrets** (`uns_sk_...`) — e.g. Dedicated Instances or self-hosted
512+
clusters with `DEPLOYMENT_MODE=dedicated` on account-service — the SDK
513+
can transparently exchange that secret for a short-lived JWT, cache it,
514+
refresh it before expiry, and send it on every request as
515+
`Authorization: Bearer <jwt>`.
516+
517+
### Synchronous usage
518+
519+
```python
520+
from unstructured_client import UnstructuredClient
521+
from unstructured_client.auth import ClientCredentials
522+
523+
client = UnstructuredClient(
524+
api_key_auth=ClientCredentials(
525+
client_secret="uns_sk_...",
526+
server_url="https://accounts.unstructuredapp.io", # account-service base URL
527+
),
528+
server_url="https://platform.unstructuredapp.io", # platform-api / core-product
529+
)
530+
531+
# Every operation automatically carries Authorization: Bearer <jwt>.
532+
client.general.partition(...)
533+
```
534+
535+
### Asynchronous usage
536+
537+
```python
538+
import asyncio
539+
from unstructured_client import UnstructuredClient
540+
from unstructured_client.auth import AsyncClientCredentials
541+
542+
async def main() -> None:
543+
auth = AsyncClientCredentials(
544+
client_secret="uns_sk_...",
545+
server_url="https://accounts.unstructuredapp.io",
546+
)
547+
async with UnstructuredClient(api_key_auth=auth) as client:
548+
await client.general.partition_async(...)
549+
550+
asyncio.run(main())
551+
```
552+
553+
### Legacy API-key bridge
554+
555+
For deployments still using legacy api-tracking keys, the same machinery
556+
is available through `LegacyKeyExchange` / `AsyncLegacyKeyExchange`. It
557+
hits the same `/auth/token-exchange` endpoint with
558+
`grant_type=api_key` and is intentionally transitional — migrate to
559+
`ClientCredentials` once client secrets are provisioned.
560+
561+
```python
562+
from unstructured_client.auth import LegacyKeyExchange
563+
564+
client = UnstructuredClient(
565+
api_key_auth=LegacyKeyExchange(
566+
api_key="your-legacy-uns_ak-key",
567+
server_url="https://accounts.unstructuredapp.io",
568+
),
569+
)
570+
```
571+
572+
### Behavior and tuning
573+
574+
- **Caching:** JWTs are held in-memory and reused until
575+
`refresh_buffer_seconds` (default **60s**) before absolute expiry.
576+
- **Concurrency:** sync callers share a `threading.Lock`, async callers
577+
share an `asyncio.Lock`. Ten concurrent requests on a cold cache drive
578+
exactly one exchange.
579+
- **Retries:** 5xx and network errors retry with exponential backoff
580+
(default `max_retries=3`). `400` / `401` fail fast with
581+
`TokenExchangeError` / `InvalidCredentialError`.
582+
- **Outage fallback:** if account-service is unreachable *and* a cached
583+
token is still within its absolute TTL, the cached token is returned
584+
and a warning is logged on `unstructured-client.auth`.
585+
- **Disabled exchange:** when the server responds with
586+
`token_exchange_enabled=False`, the call raises
587+
`TokenExchangeDisabledError` — that deployment expects the plain
588+
`api_key_auth="..."` string form instead.
589+
590+
### Backward compatibility
591+
592+
Passing a plain string still works exactly as before:
593+
594+
```python
595+
client = UnstructuredClient(api_key_auth="your-key")
596+
```
597+
598+
In that case the SDK sends `unstructured-api-key: your-key` without any
599+
token exchange, identical to pre-`auth` SDK versions.
600+
505601
### Maturity
506602

507603
This SDK is in beta, and there may be breaking changes between versions without a major version update. Therefore, we recommend pinning usage
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""End-to-end integration test for :class:`ClientCredentials` and
2+
:class:`LegacyKeyExchange`.
3+
4+
This test is **opt-in**: it only runs when every required env var is set.
5+
Point it at a deployment (e.g. the ``unsightly-koala`` dedicated-instance
6+
test cluster) that has ``DEPLOYMENT_MODE=dedicated`` and a valid client
7+
secret provisioned via account-service.
8+
9+
Required env vars
10+
-----------------
11+
12+
- ``UNS_ACCOUNTS_URL`` base URL of account-service (e.g.
13+
``https://accounts.unsightly-koala.example``)
14+
- ``UNS_CLIENT_SECRET`` ``uns_sk_...`` client secret
15+
- ``UNS_PLATFORM_API_URL`` platform-api base URL to hit after exchange
16+
17+
Optional:
18+
19+
- ``UNS_LEGACY_API_KEY`` if set, the LegacyKeyExchange path is also
20+
exercised against the same platform-api.
21+
22+
What it verifies
23+
----------------
24+
25+
1. The SDK can bootstrap a :class:`ClientCredentials` and successfully
26+
exchange the secret for a JWT against real account-service.
27+
2. A real downstream call (``jobs.list_jobs``) goes through with
28+
``Authorization: Bearer`` and returns 2xx.
29+
3. Re-using the same client does not trigger a second exchange (cache
30+
hit) because the first JWT is still within its TTL.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import os
36+
37+
import pytest
38+
39+
from unstructured_client import UnstructuredClient
40+
from unstructured_client.auth import ClientCredentials, LegacyKeyExchange
41+
from unstructured_client.models import operations
42+
43+
ACCOUNTS_URL = os.getenv("UNS_ACCOUNTS_URL")
44+
CLIENT_SECRET = os.getenv("UNS_CLIENT_SECRET")
45+
PLATFORM_API_URL = os.getenv("UNS_PLATFORM_API_URL")
46+
LEGACY_API_KEY = os.getenv("UNS_LEGACY_API_KEY")
47+
48+
49+
_REASON = (
50+
"Opt-in E2E: set UNS_ACCOUNTS_URL, UNS_CLIENT_SECRET, and "
51+
"UNS_PLATFORM_API_URL to run against a real dedicated-instance "
52+
"deployment (e.g. unsightly-koala)."
53+
)
54+
55+
56+
pytestmark = pytest.mark.skipif(
57+
not (ACCOUNTS_URL and CLIENT_SECRET and PLATFORM_API_URL),
58+
reason=_REASON,
59+
)
60+
61+
62+
def _list_jobs(session: UnstructuredClient) -> None:
63+
"""Lightweight read request that only needs an authenticated identity."""
64+
session.jobs.list_jobs(request=operations.ListJobsRequest())
65+
66+
67+
def test_client_credentials_exchange_and_list_jobs():
68+
cc = ClientCredentials(
69+
client_secret=CLIENT_SECRET, # type: ignore[arg-type]
70+
server_url=ACCOUNTS_URL, # type: ignore[arg-type]
71+
)
72+
try:
73+
session = UnstructuredClient(
74+
api_key_auth=cc,
75+
server_url=PLATFORM_API_URL,
76+
timeout_ms=60_000,
77+
)
78+
79+
_list_jobs(session)
80+
81+
# Cached exchange: internal cache now holds a JWT; a second call
82+
# should not trigger a new exchange unless we crossed the refresh
83+
# buffer, which is unlikely across two sequential requests.
84+
before_cache = cc._cache # type: ignore[attr-defined]
85+
assert before_cache is not None, "expected cache to be populated after first call"
86+
87+
_list_jobs(session)
88+
89+
after_cache = cc._cache # type: ignore[attr-defined]
90+
assert after_cache is before_cache, (
91+
"ClientCredentials re-exchanged within TTL; cache should be reused"
92+
)
93+
finally:
94+
cc.close()
95+
96+
97+
@pytest.mark.skipif(
98+
LEGACY_API_KEY is None,
99+
reason="Set UNS_LEGACY_API_KEY to also exercise the LegacyKeyExchange path.",
100+
)
101+
def test_legacy_key_exchange_and_list_jobs():
102+
lke = LegacyKeyExchange(
103+
api_key=LEGACY_API_KEY, # type: ignore[arg-type]
104+
server_url=ACCOUNTS_URL, # type: ignore[arg-type]
105+
)
106+
try:
107+
session = UnstructuredClient(
108+
api_key_auth=lke,
109+
server_url=PLATFORM_API_URL,
110+
timeout_ms=60_000,
111+
)
112+
_list_jobs(session)
113+
assert lke._cache is not None # type: ignore[attr-defined]
114+
finally:
115+
lke.close()

0 commit comments

Comments
 (0)