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 +129 -0
- iiisight/auth.py +215 -0
- iiisight/client.py +339 -0
- iiisight/content_state.py +149 -0
- iiisight/diagnostics.py +46 -0
- iiisight/errors.py +36 -0
- iiisight/image.py +98 -0
- iiisight/language.py +73 -0
- iiisight/lint.py +75 -0
- iiisight/model.py +412 -0
- iiisight/normalize.py +575 -0
- iiisight/py.typed +0 -0
- iiisight/search.py +58 -0
- iiisight/transport.py +45 -0
- iiisight/upgrade.py +301 -0
- iiisight-0.1.dist-info/METADATA +210 -0
- iiisight-0.1.dist-info/RECORD +23 -0
- iiisight-0.1.dist-info/WHEEL +5 -0
- iiisight-0.1.dist-info/licenses/LICENSE +201 -0
- iiisight-0.1.dist-info/licenses/NOTICE +4 -0
- iiisight-0.1.dist-info/scm_file_list.json +66 -0
- iiisight-0.1.dist-info/scm_version.json +8 -0
- iiisight-0.1.dist-info/top_level.txt +1 -0
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)
|