iiisight 0.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.
iiisight/__init__.py ADDED
@@ -0,0 +1,129 @@
1
+ """iiisight — a tolerant, async (and sync) Python client for consuming IIIF.
2
+
3
+ This package is the read/consume side of IIIF: fetch a manifest or collection,
4
+ normalize Presentation v2 to v3, walk the structure, and pull tile-aware image
5
+ regions. See ``PLAN.md`` for the implementation plan; this module is the public
6
+ entry point and currently exposes only the resolved package version.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as _version
11
+
12
+ from . import auth, content_state
13
+ from .auth import (
14
+ AccessService,
15
+ AccessTokenService,
16
+ LogoutService,
17
+ ProbeResult,
18
+ ProbeService,
19
+ TokenResult,
20
+ find_probe_service,
21
+ )
22
+ from .client import (
23
+ AsyncClient,
24
+ Client,
25
+ fetch,
26
+ fetch_collection,
27
+ fetch_collection_sync,
28
+ fetch_manifest,
29
+ fetch_manifest_sync,
30
+ fetch_sync,
31
+ )
32
+ from .content_state import ContentState, ContentStateError
33
+ from .diagnostics import Diagnostic, Severity
34
+ from .errors import (
35
+ FetchError,
36
+ IIISightError,
37
+ ImageServiceNotLoaded,
38
+ UnexpectedDocumentError,
39
+ UnknownScaleFactor,
40
+ )
41
+ from .image import parse_info
42
+ from .language import LanguageMap
43
+ from .lint import LintReport, lint
44
+ from .model import (
45
+ Agent,
46
+ Annotation,
47
+ AnnotationPage,
48
+ Canvas,
49
+ Collection,
50
+ ContentResource,
51
+ ImageService,
52
+ Link,
53
+ Manifest,
54
+ MetadataEntry,
55
+ Range,
56
+ Reference,
57
+ Selector,
58
+ Service,
59
+ Size,
60
+ SpecificResource,
61
+ TextualBody,
62
+ Tile,
63
+ TileSet,
64
+ )
65
+ from .normalize import normalize, parse_annotation_page
66
+ from .search import find_search_service, parse_search_response, search_url
67
+
68
+ try:
69
+ __version__ = _version("iiisight")
70
+ except PackageNotFoundError: # pragma: no cover - only hit when running from a source tree
71
+ __version__ = "0.0.0"
72
+
73
+ __all__ = [
74
+ "__version__",
75
+ "normalize",
76
+ "parse_annotation_page",
77
+ "parse_info",
78
+ "find_search_service",
79
+ "search_url",
80
+ "parse_search_response",
81
+ "auth",
82
+ "find_probe_service",
83
+ "ProbeService",
84
+ "AccessService",
85
+ "AccessTokenService",
86
+ "LogoutService",
87
+ "ProbeResult",
88
+ "TokenResult",
89
+ "lint",
90
+ "LintReport",
91
+ "content_state",
92
+ "ContentState",
93
+ "ContentStateError",
94
+ "AsyncClient",
95
+ "Client",
96
+ "fetch",
97
+ "fetch_manifest",
98
+ "fetch_collection",
99
+ "fetch_sync",
100
+ "fetch_manifest_sync",
101
+ "fetch_collection_sync",
102
+ "IIISightError",
103
+ "FetchError",
104
+ "UnexpectedDocumentError",
105
+ "ImageServiceNotLoaded",
106
+ "UnknownScaleFactor",
107
+ "Diagnostic",
108
+ "Severity",
109
+ "LanguageMap",
110
+ "Agent",
111
+ "Annotation",
112
+ "AnnotationPage",
113
+ "Canvas",
114
+ "Collection",
115
+ "ContentResource",
116
+ "ImageService",
117
+ "Link",
118
+ "Manifest",
119
+ "MetadataEntry",
120
+ "Range",
121
+ "Reference",
122
+ "Selector",
123
+ "Service",
124
+ "Size",
125
+ "SpecificResource",
126
+ "TextualBody",
127
+ "Tile",
128
+ "TileSet",
129
+ ]
iiisight/auth.py ADDED
@@ -0,0 +1,215 @@
1
+ """IIIF Authorization Flow 2.0 (metadata + non-interactive token exchange).
2
+
3
+ iiisight *describes* the auth services on a resource and can drive the
4
+ non-interactive parts of the flow — probing whether a resource is accessible and
5
+ exchanging for an access token where the token service is directly callable
6
+ (e.g. an ``external`` profile relying on cookies). The **interactive** login step
7
+ (opening the access service in a browser and reading the token via postMessage)
8
+ is inherently the host application's job and is out of scope; iiisight gives you
9
+ the service metadata to drive it.
10
+
11
+ The auth services are a nested tree (``AuthProbeService2`` → ``AuthAccessService2``
12
+ → ``AuthAccessTokenService2`` / ``AuthLogoutService2``) carrying fields outside the
13
+ normalized subset, so they are parsed from each :class:`~iiisight.model.Service`'s
14
+ preserved ``raw`` JSON.
15
+ """
16
+
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from .language import LanguageMap
22
+ from .model import Canvas, ContentResource, Described, Service
23
+
24
+ PROBE_TYPE = "AuthProbeService2"
25
+ ACCESS_TYPE = "AuthAccessService2"
26
+ TOKEN_TYPE = "AuthAccessTokenService2"
27
+ LOGOUT_TYPE = "AuthLogoutService2"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LogoutService:
32
+ """An ``AuthLogoutService2``."""
33
+
34
+ id: str
35
+ label: LanguageMap | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AccessTokenService:
40
+ """An ``AuthAccessTokenService2`` — where a token is obtained."""
41
+
42
+ id: str
43
+ error_heading: LanguageMap | None = None
44
+ error_note: LanguageMap | None = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class AccessService:
49
+ """An ``AuthAccessService2`` — where the user authenticates."""
50
+
51
+ id: str
52
+ profile: str | None = None
53
+ label: LanguageMap | None = None
54
+ heading: LanguageMap | None = None
55
+ note: LanguageMap | None = None
56
+ confirm_label: LanguageMap | None = None
57
+ token_service: AccessTokenService | None = None
58
+ logout_service: LogoutService | None = None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ProbeService:
63
+ """An ``AuthProbeService2`` — the entry point to a resource's auth flow."""
64
+
65
+ id: str
66
+ access_services: tuple[AccessService, ...] = ()
67
+
68
+ @property
69
+ def token_service(self) -> AccessTokenService | None:
70
+ """The first access token service among the access services, if any."""
71
+ for access in self.access_services:
72
+ if access.token_service is not None:
73
+ return access.token_service
74
+ return None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ProbeResult:
79
+ """The result of calling a probe service (``AuthProbeResult2``)."""
80
+
81
+ status: int
82
+ location: str | None = None
83
+ heading: LanguageMap | None = None
84
+ note: LanguageMap | None = None
85
+
86
+ @property
87
+ def accessible(self) -> bool:
88
+ """True when the resource is accessible with the credentials supplied."""
89
+ return self.status == 200
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class TokenResult:
94
+ """The result of a non-interactive token exchange."""
95
+
96
+ access_token: str | None = None
97
+ expires_in: int | None = None
98
+ error: str | None = None
99
+
100
+ @property
101
+ def ok(self) -> bool:
102
+ return self.access_token is not None
103
+
104
+
105
+ def find_probe_service(resource: Described | ContentResource | Service) -> ProbeService | None:
106
+ """Find the auth probe service on a resource, if it is access-controlled.
107
+
108
+ Accepts a :class:`Canvas` (searches its images' services), a
109
+ :class:`ContentResource`, a bare :class:`Service`, or any resource with a
110
+ ``services`` list.
111
+ """
112
+ for service in _candidate_services(resource):
113
+ probe = probe_service_from(service)
114
+ if probe is not None:
115
+ return probe
116
+ return None
117
+
118
+
119
+ def probe_service_from(service: Service) -> ProbeService | None:
120
+ """Parse a :class:`Service` into a :class:`ProbeService`, or ``None``."""
121
+ raw = service.raw or {}
122
+ if (service.type or raw.get("type")) != PROBE_TYPE:
123
+ return None
124
+ access = tuple(
125
+ _access_service(entry)
126
+ for entry in _as_list(raw.get("service"))
127
+ if isinstance(entry, Mapping) and entry.get("type") == ACCESS_TYPE
128
+ )
129
+ return ProbeService(id=service.id, access_services=access)
130
+
131
+
132
+ def parse_probe_result(document: Mapping[str, Any]) -> ProbeResult:
133
+ """Parse an ``AuthProbeResult2`` document."""
134
+ location = document.get("location")
135
+ if isinstance(location, Mapping):
136
+ location_id = location.get("id") or location.get("@id")
137
+ elif isinstance(location, str):
138
+ location_id = location
139
+ else:
140
+ location_id = None
141
+ return ProbeResult(
142
+ status=int(document.get("status", 200)),
143
+ location=str(location_id) if location_id else None,
144
+ heading=_lang(document.get("heading")),
145
+ note=_lang(document.get("note")),
146
+ )
147
+
148
+
149
+ def parse_token_result(document: Mapping[str, Any]) -> TokenResult:
150
+ """Parse an ``AuthAccessToken2`` (or error) document."""
151
+ token = document.get("accessToken")
152
+ if token:
153
+ return TokenResult(access_token=str(token), expires_in=document.get("expiresIn"))
154
+ error = document.get("profile") or document.get("error") or document.get("type")
155
+ return TokenResult(error=str(error) if error else "unknown")
156
+
157
+
158
+ def _candidate_services(resource: Described | ContentResource | Service) -> list[Service]:
159
+ if isinstance(resource, Service):
160
+ return [resource]
161
+ if isinstance(resource, ContentResource):
162
+ return list(resource.services)
163
+ if isinstance(resource, Canvas):
164
+ collected: list[Service] = []
165
+ for image in resource.images:
166
+ collected.extend(image.services)
167
+ collected.extend(resource.services)
168
+ return collected
169
+ return list(getattr(resource, "services", ()) or ())
170
+
171
+
172
+ def _access_service(raw: Mapping[str, Any]) -> AccessService:
173
+ token_service: AccessTokenService | None = None
174
+ logout_service: LogoutService | None = None
175
+ for entry in _as_list(raw.get("service")):
176
+ if not isinstance(entry, Mapping):
177
+ continue
178
+ if entry.get("type") == TOKEN_TYPE:
179
+ token_service = AccessTokenService(
180
+ id=str(entry.get("id", "")),
181
+ error_heading=_lang(entry.get("errorHeading")),
182
+ error_note=_lang(entry.get("errorNote")),
183
+ )
184
+ elif entry.get("type") == LOGOUT_TYPE:
185
+ logout_service = LogoutService(
186
+ id=str(entry.get("id", "")), label=_lang(entry.get("label"))
187
+ )
188
+ return AccessService(
189
+ id=str(raw.get("id", "")),
190
+ profile=raw.get("profile"),
191
+ label=_lang(raw.get("label")),
192
+ heading=_lang(raw.get("heading")),
193
+ note=_lang(raw.get("note")),
194
+ confirm_label=_lang(raw.get("confirmLabel")),
195
+ token_service=token_service,
196
+ logout_service=logout_service,
197
+ )
198
+
199
+
200
+ def _lang(value: Any) -> LanguageMap | None:
201
+ if isinstance(value, Mapping):
202
+ return LanguageMap(
203
+ {key: val if isinstance(val, list) else [val] for key, val in value.items()}
204
+ )
205
+ if isinstance(value, str):
206
+ return LanguageMap({"none": [value]})
207
+ return None
208
+
209
+
210
+ def _as_list(value: Any) -> list[Any]:
211
+ if value is None:
212
+ return []
213
+ if isinstance(value, list):
214
+ return value
215
+ return [value]
iiisight/client.py ADDED
@@ -0,0 +1,339 @@
1
+ """The client facades: :class:`AsyncClient` (flagship) and :class:`Client` (sync).
2
+
3
+ Both fetch IIIF documents over HTTP and hand the body to
4
+ :func:`~iiisight.normalize.normalize`, returning the normalized model with its
5
+ diagnostics attached. All network I/O is explicit here — the model never fetches
6
+ behind attribute access. ``expand()`` resolves a collection's references, and
7
+ ``resolve()`` fetches a single reference; both are the only ways to trigger the
8
+ follow-on requests a collection or reference implies.
9
+
10
+ The two facades share every pure helper in this module; only the ``httpx`` call
11
+ differs (async vs. sync). Class names mirror ``httpx`` so its users have no
12
+ surprise.
13
+ """
14
+
15
+ import asyncio
16
+ from collections.abc import Mapping
17
+ from dataclasses import replace
18
+ from typing import TypeVar
19
+
20
+ import httpx
21
+
22
+ from .auth import (
23
+ AccessTokenService,
24
+ ProbeResult,
25
+ ProbeService,
26
+ TokenResult,
27
+ parse_probe_result,
28
+ parse_token_result,
29
+ )
30
+ from .errors import FetchError, UnexpectedDocumentError
31
+ from .image import parse_info
32
+ from .model import (
33
+ AnnotationPage,
34
+ Canvas,
35
+ Collection,
36
+ Described,
37
+ ImageService,
38
+ Manifest,
39
+ Reference,
40
+ Service,
41
+ )
42
+ from .normalize import normalize
43
+ from .search import parse_search_response, search_url
44
+ from .transport import ACCEPT, DEFAULT_TIMEOUT, afetch_json, fetch_json
45
+
46
+ #: Default cap on concurrent fetches when expanding a collection (async only).
47
+ DEFAULT_CONCURRENCY = 8
48
+
49
+ _Doc = TypeVar("_Doc", Manifest, Collection)
50
+
51
+
52
+ def _image_service_of(target: ImageService | Canvas) -> ImageService:
53
+ """Resolve an ImageService from a service or a canvas's primary image."""
54
+ if isinstance(target, ImageService):
55
+ return target
56
+ if isinstance(target, Canvas):
57
+ service = target.image_service
58
+ if service is None:
59
+ raise UnexpectedDocumentError("canvas has no image service to load")
60
+ return service
61
+ raise UnexpectedDocumentError(f"cannot load info for a {type(target).__name__}")
62
+
63
+
64
+ def _info_url(service: ImageService) -> str:
65
+ return f"{service.id.rstrip('/')}/info.json"
66
+
67
+
68
+ def _finalize(document: Manifest | Collection, final_url: str) -> Manifest | Collection:
69
+ """Fill an absent document id from the URL it was fetched from."""
70
+ if not document.id:
71
+ return replace(document, id=final_url)
72
+ return document
73
+
74
+
75
+ def _expect(document: Manifest | Collection, kind: type[_Doc]) -> _Doc:
76
+ """Return ``document`` if it is ``kind``, else raise UnexpectedDocumentError."""
77
+ if not isinstance(document, kind):
78
+ raise UnexpectedDocumentError(
79
+ f"expected a {kind.__name__} but fetched a {type(document).__name__}"
80
+ )
81
+ return document
82
+
83
+
84
+ class AsyncClient:
85
+ """An async IIIF client over a shared ``httpx.AsyncClient`` connection pool.
86
+
87
+ Use as an async context manager. Pass ``http_client`` to reuse an existing
88
+ ``httpx.AsyncClient`` (e.g. with custom auth/caching); iiisight then does not
89
+ close it.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ http_client: httpx.AsyncClient | None = None,
96
+ timeout: float = DEFAULT_TIMEOUT,
97
+ concurrency: int = DEFAULT_CONCURRENCY,
98
+ ):
99
+ self._owns_client = http_client is None
100
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
101
+ self._concurrency = concurrency
102
+
103
+ async def __aenter__(self) -> "AsyncClient":
104
+ return self
105
+
106
+ async def __aexit__(self, *exc_info: object) -> None:
107
+ await self.aclose()
108
+
109
+ async def aclose(self) -> None:
110
+ """Close the underlying httpx client, unless it was supplied externally."""
111
+ if self._owns_client:
112
+ await self._http.aclose()
113
+
114
+ async def fetch(self, url: str) -> Manifest | Collection:
115
+ """Fetch and normalize a manifest or collection (auto-detected)."""
116
+ body, final_url = await afetch_json(self._http, url)
117
+ return _finalize(normalize(body), final_url)
118
+
119
+ async def fetch_manifest(self, url: str) -> Manifest:
120
+ """Fetch a URL expected to be a Manifest."""
121
+ return _expect(await self.fetch(url), Manifest)
122
+
123
+ async def fetch_collection(self, url: str) -> Collection:
124
+ """Fetch a URL expected to be a Collection."""
125
+ return _expect(await self.fetch(url), Collection)
126
+
127
+ async def resolve(self, reference: Reference) -> Manifest | Collection:
128
+ """Fetch the document a reference points at."""
129
+ return await self.fetch(reference.id)
130
+
131
+ async def expand(self, collection: Collection) -> list[Manifest | Collection]:
132
+ """Resolve a collection's items one level deep, preserving order.
133
+
134
+ Embedded items are returned as-is; references are fetched (concurrently,
135
+ bounded by the client's ``concurrency``).
136
+ """
137
+ semaphore = asyncio.Semaphore(self._concurrency)
138
+
139
+ async def _one(item: Manifest | Collection | Reference) -> Manifest | Collection:
140
+ if isinstance(item, Reference):
141
+ async with semaphore:
142
+ return await self.fetch(item.id)
143
+ return item
144
+
145
+ return list(await asyncio.gather(*(_one(item) for item in collection.items)))
146
+
147
+ async def load_info(self, target: ImageService | Canvas) -> ImageService:
148
+ """Fetch an image service's ``info.json`` and return the loaded service."""
149
+ service = _image_service_of(target)
150
+ body, _ = await afetch_json(self._http, _info_url(service))
151
+ return parse_info(body)
152
+
153
+ async def fetch_region(
154
+ self, service: ImageService, region: str = "full", size: str = "max", **url_kwargs: str
155
+ ) -> bytes:
156
+ """Fetch the image bytes for a region/size from an image service."""
157
+ url = service.url(region=region, size=size, **url_kwargs)
158
+ try:
159
+ response = await self._http.get(url, follow_redirects=True)
160
+ response.raise_for_status()
161
+ return response.content
162
+ except httpx.HTTPError as exc:
163
+ raise FetchError(url, str(exc)) from exc
164
+
165
+ async def search(
166
+ self,
167
+ target: Described | Service | str,
168
+ query: str,
169
+ params: Mapping[str, str] | None = None,
170
+ ) -> AnnotationPage:
171
+ """Run a Content Search against a resource's search service.
172
+
173
+ Args:
174
+ target: A manifest/canvas (its search service is discovered), a
175
+ :class:`Service`, or a search base URL.
176
+ query: The search query string.
177
+ params: Extra query parameters (e.g. ``motivation``, ``date``).
178
+
179
+ Returns:
180
+ An :class:`AnnotationPage` of hit annotations.
181
+ """
182
+ body, _ = await afetch_json(self._http, search_url(target, query, params))
183
+ return parse_search_response(body)
184
+
185
+ async def probe(self, probe_service: ProbeService, token: str | None = None) -> ProbeResult:
186
+ """Call an auth probe service to check access, optionally with a token."""
187
+ headers = {"Accept": ACCEPT}
188
+ if token:
189
+ headers["Authorization"] = f"Bearer {token}"
190
+ try:
191
+ response = await self._http.get(
192
+ probe_service.id, headers=headers, follow_redirects=True
193
+ )
194
+ response.raise_for_status()
195
+ return parse_probe_result(response.json())
196
+ except httpx.HTTPError as exc:
197
+ raise FetchError(probe_service.id, str(exc)) from exc
198
+ except ValueError as exc:
199
+ raise FetchError(probe_service.id, f"probe response was not valid JSON: {exc}") from exc
200
+
201
+ async def access_token(self, token_service: AccessTokenService) -> TokenResult:
202
+ """Non-interactive token exchange against an access token service."""
203
+ body, _ = await afetch_json(self._http, token_service.id)
204
+ return parse_token_result(body)
205
+
206
+
207
+ class Client:
208
+ """A synchronous IIIF client — the same surface as :class:`AsyncClient`
209
+ without ``await``. Use as a context manager."""
210
+
211
+ def __init__(
212
+ self,
213
+ *,
214
+ http_client: httpx.Client | None = None,
215
+ timeout: float = DEFAULT_TIMEOUT,
216
+ ):
217
+ self._owns_client = http_client is None
218
+ self._http = http_client or httpx.Client(timeout=timeout)
219
+
220
+ def __enter__(self) -> "Client":
221
+ return self
222
+
223
+ def __exit__(self, *exc_info: object) -> None:
224
+ self.close()
225
+
226
+ def close(self) -> None:
227
+ """Close the underlying httpx client, unless it was supplied externally."""
228
+ if self._owns_client:
229
+ self._http.close()
230
+
231
+ def fetch(self, url: str) -> Manifest | Collection:
232
+ """Fetch and normalize a manifest or collection (auto-detected)."""
233
+ body, final_url = fetch_json(self._http, url)
234
+ return _finalize(normalize(body), final_url)
235
+
236
+ def fetch_manifest(self, url: str) -> Manifest:
237
+ """Fetch a URL expected to be a Manifest."""
238
+ return _expect(self.fetch(url), Manifest)
239
+
240
+ def fetch_collection(self, url: str) -> Collection:
241
+ """Fetch a URL expected to be a Collection."""
242
+ return _expect(self.fetch(url), Collection)
243
+
244
+ def resolve(self, reference: Reference) -> Manifest | Collection:
245
+ """Fetch the document a reference points at."""
246
+ return self.fetch(reference.id)
247
+
248
+ def expand(self, collection: Collection) -> list[Manifest | Collection]:
249
+ """Resolve a collection's items one level deep, preserving order."""
250
+ return [
251
+ self.fetch(item.id) if isinstance(item, Reference) else item
252
+ for item in collection.items
253
+ ]
254
+
255
+ def load_info(self, target: ImageService | Canvas) -> ImageService:
256
+ """Fetch an image service's ``info.json`` and return the loaded service."""
257
+ service = _image_service_of(target)
258
+ body, _ = fetch_json(self._http, _info_url(service))
259
+ return parse_info(body)
260
+
261
+ def fetch_region(
262
+ self, service: ImageService, region: str = "full", size: str = "max", **url_kwargs: str
263
+ ) -> bytes:
264
+ """Fetch the image bytes for a region/size from an image service."""
265
+ url = service.url(region=region, size=size, **url_kwargs)
266
+ try:
267
+ response = self._http.get(url, follow_redirects=True)
268
+ response.raise_for_status()
269
+ return response.content
270
+ except httpx.HTTPError as exc:
271
+ raise FetchError(url, str(exc)) from exc
272
+
273
+ def search(
274
+ self,
275
+ target: Described | Service | str,
276
+ query: str,
277
+ params: Mapping[str, str] | None = None,
278
+ ) -> AnnotationPage:
279
+ """Run a Content Search against a resource's search service."""
280
+ body, _ = fetch_json(self._http, search_url(target, query, params))
281
+ return parse_search_response(body)
282
+
283
+ def probe(self, probe_service: ProbeService, token: str | None = None) -> ProbeResult:
284
+ """Call an auth probe service to check access, optionally with a token."""
285
+ headers = {"Accept": ACCEPT}
286
+ if token:
287
+ headers["Authorization"] = f"Bearer {token}"
288
+ try:
289
+ response = self._http.get(probe_service.id, headers=headers, follow_redirects=True)
290
+ response.raise_for_status()
291
+ return parse_probe_result(response.json())
292
+ except httpx.HTTPError as exc:
293
+ raise FetchError(probe_service.id, str(exc)) from exc
294
+ except ValueError as exc:
295
+ raise FetchError(probe_service.id, f"probe response was not valid JSON: {exc}") from exc
296
+
297
+ def access_token(self, token_service: AccessTokenService) -> TokenResult:
298
+ """Non-interactive token exchange against an access token service."""
299
+ body, _ = fetch_json(self._http, token_service.id)
300
+ return parse_token_result(body)
301
+
302
+
303
+ # --- Module-level convenience -----------------------------------------------
304
+
305
+
306
+ async def fetch(url: str) -> Manifest | Collection:
307
+ """One-shot async fetch of a manifest or collection."""
308
+ async with AsyncClient() as client:
309
+ return await client.fetch(url)
310
+
311
+
312
+ async def fetch_manifest(url: str) -> Manifest:
313
+ """One-shot async fetch of a Manifest."""
314
+ async with AsyncClient() as client:
315
+ return await client.fetch_manifest(url)
316
+
317
+
318
+ async def fetch_collection(url: str) -> Collection:
319
+ """One-shot async fetch of a Collection."""
320
+ async with AsyncClient() as client:
321
+ return await client.fetch_collection(url)
322
+
323
+
324
+ def fetch_sync(url: str) -> Manifest | Collection:
325
+ """One-shot sync fetch of a manifest or collection."""
326
+ with Client() as client:
327
+ return client.fetch(url)
328
+
329
+
330
+ def fetch_manifest_sync(url: str) -> Manifest:
331
+ """One-shot sync fetch of a Manifest."""
332
+ with Client() as client:
333
+ return client.fetch_manifest(url)
334
+
335
+
336
+ def fetch_collection_sync(url: str) -> Collection:
337
+ """One-shot sync fetch of a Collection."""
338
+ with Client() as client:
339
+ return client.fetch_collection(url)