opds-client 0.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.
- opds_client/__init__.py +47 -0
- opds_client/client.py +385 -0
- opds_client/exceptions.py +93 -0
- opds_client/models/__init__.py +51 -0
- opds_client/models/acquisition.py +85 -0
- opds_client/models/base.py +189 -0
- opds_client/models/feed.py +256 -0
- opds_client/models/publication.py +205 -0
- opds_client/parser.py +262 -0
- opds_client/utils.py +192 -0
- opds_client-0.0.1.dist-info/METADATA +521 -0
- opds_client-0.0.1.dist-info/RECORD +15 -0
- opds_client-0.0.1.dist-info/WHEEL +5 -0
- opds_client-0.0.1.dist-info/licenses/LICENSE +21 -0
- opds_client-0.0.1.dist-info/top_level.txt +1 -0
opds_client/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OPDS Client Library supporting OPDS 1.2 and OPDS 2.0."""
|
|
2
|
+
|
|
3
|
+
from opds_client.client import OPDSClient
|
|
4
|
+
from opds_client.models import (
|
|
5
|
+
AcquisitionLink,
|
|
6
|
+
Facet,
|
|
7
|
+
Group,
|
|
8
|
+
Link,
|
|
9
|
+
Metadata,
|
|
10
|
+
NavigationLink,
|
|
11
|
+
OPDSFeed,
|
|
12
|
+
OPDSPublication,
|
|
13
|
+
Publication,
|
|
14
|
+
)
|
|
15
|
+
from opds_client.opds12 import OPDS12Client
|
|
16
|
+
from opds_client.opds12.models import (
|
|
17
|
+
Entry as OPDS12Entry,
|
|
18
|
+
)
|
|
19
|
+
from opds_client.opds12.models import (
|
|
20
|
+
FacetGroup,
|
|
21
|
+
)
|
|
22
|
+
from opds_client.opds12.models import (
|
|
23
|
+
Feed as OPDS12Feed,
|
|
24
|
+
)
|
|
25
|
+
from opds_client.opds12.models import (
|
|
26
|
+
Link as OPDS12Link,
|
|
27
|
+
)
|
|
28
|
+
from opds_client.opds12.parser import OPDS12Parser
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"OPDSClient",
|
|
32
|
+
"Link",
|
|
33
|
+
"Metadata",
|
|
34
|
+
"Publication",
|
|
35
|
+
"OPDSPublication",
|
|
36
|
+
"OPDSFeed",
|
|
37
|
+
"NavigationLink",
|
|
38
|
+
"Facet",
|
|
39
|
+
"Group",
|
|
40
|
+
"AcquisitionLink",
|
|
41
|
+
"OPDS12Client",
|
|
42
|
+
"OPDS12Feed",
|
|
43
|
+
"OPDS12Entry",
|
|
44
|
+
"OPDS12Link",
|
|
45
|
+
"FacetGroup",
|
|
46
|
+
"OPDS12Parser",
|
|
47
|
+
]
|
opds_client/client.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""OPDS 2.0 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
9
|
+
from requests.auth import HTTPBasicAuth
|
|
10
|
+
|
|
11
|
+
from opds_client.exceptions import (
|
|
12
|
+
OPDSHTTPError,
|
|
13
|
+
OPDSNavigationError,
|
|
14
|
+
OPDSParseError,
|
|
15
|
+
OPDSValidationError,
|
|
16
|
+
SearchError,
|
|
17
|
+
)
|
|
18
|
+
from opds_client.models import Link, OPDSFeed, OPDSPublication
|
|
19
|
+
from opds_client.parser import OPDSParser, URITemplateResolver
|
|
20
|
+
from opds_client.utils import merge_urls
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OPDSClient:
|
|
24
|
+
"""Client for OPDS 2.0 catalogs."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_HEADERS = {
|
|
27
|
+
"Accept": "application/opds+json, application/json, */*",
|
|
28
|
+
"User-Agent": "OPDS-Client/1.0",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
username: str | None = None,
|
|
34
|
+
password: str | None = None,
|
|
35
|
+
auth: requests.auth.AuthBase | None = None,
|
|
36
|
+
timeout: int = 30,
|
|
37
|
+
max_retries: int = 3,
|
|
38
|
+
backoff_factor: float = 0.5,
|
|
39
|
+
session: requests.Session | None = None,
|
|
40
|
+
parser: OPDSParser | None = None,
|
|
41
|
+
origin: str | None = None,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize OPDS client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
username: Username for HTTP Basic Auth.
|
|
47
|
+
password: Password for HTTP Basic Auth.
|
|
48
|
+
auth: Custom auth object (overrides username/password).
|
|
49
|
+
timeout: Request timeout in seconds.
|
|
50
|
+
max_retries: Maximum number of retries on error.
|
|
51
|
+
backoff_factor: Backoff factor for retries.
|
|
52
|
+
session: Optional requests session to reuse.
|
|
53
|
+
parser: Optional OPDS parser instance.
|
|
54
|
+
origin: Optional base URL to prepend to relative URLs.
|
|
55
|
+
"""
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.session = session or requests.Session()
|
|
58
|
+
self.origin = origin
|
|
59
|
+
|
|
60
|
+
# Configure authentication
|
|
61
|
+
if auth is not None:
|
|
62
|
+
self.session.auth = auth
|
|
63
|
+
elif username is not None and password is not None:
|
|
64
|
+
self.session.auth = HTTPBasicAuth(username, password)
|
|
65
|
+
|
|
66
|
+
if max_retries > 0:
|
|
67
|
+
retry_strategy = Retry(
|
|
68
|
+
total=max_retries,
|
|
69
|
+
backoff_factor=backoff_factor,
|
|
70
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
71
|
+
allowed_methods=["GET", "POST"],
|
|
72
|
+
)
|
|
73
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
74
|
+
self.session.mount("http://", adapter)
|
|
75
|
+
self.session.mount("https://", adapter)
|
|
76
|
+
|
|
77
|
+
self.parser = parser or OPDSParser()
|
|
78
|
+
|
|
79
|
+
def _prepare_url(self, url: str) -> str:
|
|
80
|
+
"""Prepare URL by merging with origin if needed.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
url: URL string.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Merged absolute URL.
|
|
87
|
+
"""
|
|
88
|
+
if self.origin is not None:
|
|
89
|
+
return merge_urls(self.origin, url)
|
|
90
|
+
return url
|
|
91
|
+
|
|
92
|
+
def fetch_feed(self, url: str) -> "OPDSFeed":
|
|
93
|
+
"""Fetch and parse an OPDS feed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
url: URL of the OPDS feed.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Parsed OPDSFeed object.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
OPDSHTTPError: If HTTP request fails.
|
|
103
|
+
OPDSParseError: If parsing fails.
|
|
104
|
+
OPDSValidationError: If validation fails.
|
|
105
|
+
OPDSNavigationError: If navigation fails.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
response = self.session.get(
|
|
109
|
+
self._prepare_url(url),
|
|
110
|
+
headers=self.DEFAULT_HEADERS,
|
|
111
|
+
timeout=self.timeout,
|
|
112
|
+
)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
except requests.exceptions.RequestException as e:
|
|
115
|
+
if hasattr(e, "response") and e.response is not None:
|
|
116
|
+
raise OPDSHTTPError(
|
|
117
|
+
f"HTTP error {e.response.status_code}",
|
|
118
|
+
e.response.status_code,
|
|
119
|
+
self._prepare_url(url),
|
|
120
|
+
e.response.text,
|
|
121
|
+
) from e
|
|
122
|
+
raise OPDSHTTPError(str(e), 0, self._prepare_url(url)) from e
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
feed = self.parser.parse(response.text)
|
|
126
|
+
feed._url = url # type: ignore[attr-defined]
|
|
127
|
+
return feed
|
|
128
|
+
except (OPDSParseError, OPDSValidationError) as e:
|
|
129
|
+
raise e
|
|
130
|
+
|
|
131
|
+
def fetch_publication(self, url: str) -> "OPDSPublication":
|
|
132
|
+
"""Fetch and parse an OPDS publication.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
url: URL of the OPDS publication.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Parsed OPDSPublication object.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
OPDSHTTPError: If HTTP request fails.
|
|
142
|
+
OPDSParseError: If parsing fails.
|
|
143
|
+
OPDSValidationError: If validation fails.
|
|
144
|
+
"""
|
|
145
|
+
headers = self.DEFAULT_HEADERS.copy()
|
|
146
|
+
headers["Accept"] = (
|
|
147
|
+
"application/opds-publication+json, application/opds+json, " +
|
|
148
|
+
"application/json, */*"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
response = self.session.get(
|
|
153
|
+
self._prepare_url(url), headers=headers, timeout=self.timeout
|
|
154
|
+
)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
except requests.exceptions.RequestException as e:
|
|
157
|
+
if hasattr(e, "response") and e.response is not None:
|
|
158
|
+
raise OPDSHTTPError(
|
|
159
|
+
f"HTTP error {e.response.status_code}",
|
|
160
|
+
e.response.status_code,
|
|
161
|
+
self._prepare_url(url),
|
|
162
|
+
e.response.text,
|
|
163
|
+
) from e
|
|
164
|
+
raise OPDSHTTPError(str(e), 0, self._prepare_url(url)) from e
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
publication = self.parser.parse_publication(response.text)
|
|
168
|
+
publication._url = self._prepare_url(url) # type: ignore[attr-defined]
|
|
169
|
+
return publication
|
|
170
|
+
except (OPDSParseError, OPDSValidationError) as e:
|
|
171
|
+
raise e
|
|
172
|
+
|
|
173
|
+
def follow_link(self, link: "Link", **params: str | int | float) -> "OPDSFeed":
|
|
174
|
+
"""Follow a link and fetch the target feed.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
link: Link object to follow.
|
|
178
|
+
**params: Parameters for URI template resolution.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Parsed OPDSFeed object.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
OPDSNavigationError: If link cannot be followed.
|
|
185
|
+
OPDSHTTPError: If HTTP request fails.
|
|
186
|
+
"""
|
|
187
|
+
if link.templated:
|
|
188
|
+
url = URITemplateResolver.fill_template(str(link.href), **params)
|
|
189
|
+
else:
|
|
190
|
+
url = str(link.href)
|
|
191
|
+
|
|
192
|
+
return self.fetch_feed(self._prepare_url(url))
|
|
193
|
+
|
|
194
|
+
def navigate_to(
|
|
195
|
+
self, feed: "OPDSFeed", rel: str, **params: str | int | float
|
|
196
|
+
) -> "OPDSFeed":
|
|
197
|
+
"""Navigate to a feed using a link relation.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
feed: Current OPDS feed.
|
|
201
|
+
rel: Link relation to follow.
|
|
202
|
+
**params: Parameters for URI template resolution.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Parsed OPDSFeed object.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
OPDSNavigationError: If link with relation not found.
|
|
209
|
+
"""
|
|
210
|
+
link = feed.get_link_by_rel(rel)
|
|
211
|
+
if link is None:
|
|
212
|
+
raise OPDSNavigationError(f"Link with relation '{rel}' not found")
|
|
213
|
+
|
|
214
|
+
return self.follow_link(link, **params)
|
|
215
|
+
|
|
216
|
+
def get_link_by_rel(self, feed: "OPDSFeed", rel: str) -> Optional["Link"]:
|
|
217
|
+
"""Get a link by relation from feed.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
feed: OPDS feed.
|
|
221
|
+
rel: Link relation to find.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Link object or None if not found.
|
|
225
|
+
"""
|
|
226
|
+
return feed.get_link_by_rel(rel)
|
|
227
|
+
|
|
228
|
+
def search(
|
|
229
|
+
self,
|
|
230
|
+
feed: "OPDSFeed",
|
|
231
|
+
query: str,
|
|
232
|
+
**filters: str | int | float,
|
|
233
|
+
) -> "OPDSFeed":
|
|
234
|
+
"""Search an OPDS feed.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
feed: OPDS feed with search link.
|
|
238
|
+
query: Search query string.
|
|
239
|
+
**filters: Additional filter parameters.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Parsed OPDSFeed object with search results.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
SearchError: If search is not supported.
|
|
246
|
+
OPDSHTTPError: If HTTP request fails.
|
|
247
|
+
OPDSNavigationError: If navigation fails.
|
|
248
|
+
"""
|
|
249
|
+
search_link = feed.get_search_link()
|
|
250
|
+
if search_link is None:
|
|
251
|
+
raise SearchError("Search is not supported by this catalog")
|
|
252
|
+
|
|
253
|
+
params: dict[str, str] = {"query": query}
|
|
254
|
+
if filters:
|
|
255
|
+
params.update({str(k): str(v) for k, v in filters.items()})
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
return self.follow_link(search_link, **params)
|
|
259
|
+
except OPDSHTTPError as e:
|
|
260
|
+
raise SearchError(f"Search failed: {e}") from e
|
|
261
|
+
|
|
262
|
+
def get_next_page(self, feed: "OPDSFeed") -> Optional["OPDSFeed"]:
|
|
263
|
+
"""Get the next page of results.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
feed: Current OPDS feed.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Next page OPDSFeed or None if no next page.
|
|
270
|
+
"""
|
|
271
|
+
next_link = feed.get_next_link()
|
|
272
|
+
if next_link is None:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
return self.follow_link(next_link)
|
|
276
|
+
|
|
277
|
+
def get_previous_page(self, feed: "OPDSFeed") -> Optional["OPDSFeed"]:
|
|
278
|
+
"""Get the previous page of results.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
feed: Current OPDS feed.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Previous page OPDSFeed or None if no previous page.
|
|
285
|
+
"""
|
|
286
|
+
prev_link = feed.get_previous_link()
|
|
287
|
+
if prev_link is None:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
return self.follow_link(prev_link)
|
|
291
|
+
|
|
292
|
+
def get_first_page(self, feed: "OPDSFeed") -> Optional["OPDSFeed"]:
|
|
293
|
+
"""Get the first page of results.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
feed: Current OPDS feed.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
First page OPDSFeed or None if already on first page.
|
|
300
|
+
"""
|
|
301
|
+
first_link = feed.get_first_link()
|
|
302
|
+
if first_link is None:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
return self.follow_link(first_link)
|
|
306
|
+
|
|
307
|
+
def get_last_page(self, feed: "OPDSFeed") -> Optional["OPDSFeed"]:
|
|
308
|
+
"""Get the last page of results.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
feed: Current OPDS feed.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Last page OPDSFeed or None if already on last page.
|
|
315
|
+
"""
|
|
316
|
+
last_link = feed.get_last_link()
|
|
317
|
+
if last_link is None:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
return self.follow_link(last_link)
|
|
321
|
+
|
|
322
|
+
def follow_navigation(self, feed: "OPDSFeed", title: str) -> "OPDSFeed":
|
|
323
|
+
"""Follow a navigation link by title.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
feed: OPDS feed with navigation.
|
|
327
|
+
title: Title of navigation link to follow.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Parsed OPDSFeed object.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
OPDSNavigationError: If navigation link not found.
|
|
334
|
+
"""
|
|
335
|
+
if feed.navigation is None:
|
|
336
|
+
raise OPDSNavigationError("Feed has no navigation links")
|
|
337
|
+
|
|
338
|
+
for nav_link in feed.navigation:
|
|
339
|
+
if nav_link.title == title:
|
|
340
|
+
return self.follow_link(nav_link)
|
|
341
|
+
|
|
342
|
+
raise OPDSNavigationError(f"Navigation link with title '{title}' not found")
|
|
343
|
+
|
|
344
|
+
def follow_facet(
|
|
345
|
+
self, feed: "OPDSFeed", facet_title: str, link_title: str
|
|
346
|
+
) -> "OPDSFeed":
|
|
347
|
+
"""Follow a facet link by facet and link title.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
feed: OPDS feed with facets.
|
|
351
|
+
facet_title: Title of the facet group.
|
|
352
|
+
link_title: Title of the facet link to follow.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Parsed OPDSFeed object.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
OPDSNavigationError: If facet or link not found.
|
|
359
|
+
"""
|
|
360
|
+
facet = feed.get_facets_by_title(facet_title)
|
|
361
|
+
if facet is None:
|
|
362
|
+
raise OPDSNavigationError(f"Facet '{facet_title}' not found")
|
|
363
|
+
|
|
364
|
+
link = facet.get_facet_by_title(link_title)
|
|
365
|
+
if link is None:
|
|
366
|
+
raise OPDSNavigationError(f"Facet link '{link_title}' not found")
|
|
367
|
+
|
|
368
|
+
return self.follow_link(link)
|
|
369
|
+
|
|
370
|
+
def close(self) -> None:
|
|
371
|
+
"""Close the underlying session."""
|
|
372
|
+
self.session.close()
|
|
373
|
+
|
|
374
|
+
def __enter__(self) -> "OPDSClient":
|
|
375
|
+
"""Context manager entry."""
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
def __exit__(
|
|
379
|
+
self,
|
|
380
|
+
exc_type: Optional[type],
|
|
381
|
+
exc_val: Optional[BaseException],
|
|
382
|
+
exc_tb: Optional[object],
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Context manager exit."""
|
|
385
|
+
self.close()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Custom exceptions for OPDS client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OPDSClientError(Exception):
|
|
7
|
+
"""Base exception for OPDS client errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OPDSParseError(OPDSClientError):
|
|
13
|
+
"""Error parsing OPDS JSON."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, original_error: Exception | None = None):
|
|
16
|
+
"""Initialize OPDSParseError.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
message: Error message.
|
|
20
|
+
original_error: Original exception that caused this error.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.original_error = original_error
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OPDSValidationError(OPDSClientError):
|
|
27
|
+
"""Error validating OPDS data against schema."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, validation_errors: list[str] | None = None):
|
|
30
|
+
"""Initialize OPDSValidationError.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
message: Error message.
|
|
34
|
+
validation_errors: List of validation error details.
|
|
35
|
+
"""
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.validation_errors = validation_errors or []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidOPDSFeedError(OPDSValidationError):
|
|
41
|
+
"""Feed doesn't meet OPDS 2.0 requirements."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OPDSHTTPError(OPDSClientError):
|
|
47
|
+
"""HTTP error from OPDS server."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
message: str,
|
|
52
|
+
status_code: int,
|
|
53
|
+
url: str,
|
|
54
|
+
response_body: str | None = None,
|
|
55
|
+
):
|
|
56
|
+
"""Initialize OPDSHTTPError.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
message: Error message.
|
|
60
|
+
status_code: HTTP status code.
|
|
61
|
+
url: URL that caused the error.
|
|
62
|
+
response_body: Optional response body for debugging.
|
|
63
|
+
"""
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.status_code = status_code
|
|
66
|
+
self.url = url
|
|
67
|
+
self.response_body = response_body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OPDSNavigationError(OPDSClientError):
|
|
71
|
+
"""Error navigating OPDS feed."""
|
|
72
|
+
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class OPDSRetryError(OPDSClientError):
|
|
77
|
+
"""Error indicating operation should be retried."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, retry_after: int | None = None):
|
|
80
|
+
"""Initialize OPDSRetryError.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: Error message.
|
|
84
|
+
retry_after: Optional seconds to wait before retrying.
|
|
85
|
+
"""
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.retry_after = retry_after
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SearchError(OPDSClientError):
|
|
91
|
+
"""Error during search operation."""
|
|
92
|
+
|
|
93
|
+
pass
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pydantic models exports."""
|
|
2
|
+
|
|
3
|
+
from opds_client.models.acquisition import (
|
|
4
|
+
AcquisitionFlow,
|
|
5
|
+
AcquisitionManager,
|
|
6
|
+
)
|
|
7
|
+
from opds_client.models.base import (
|
|
8
|
+
FacetLink,
|
|
9
|
+
ImageLink,
|
|
10
|
+
IndirectAcquisition,
|
|
11
|
+
Link,
|
|
12
|
+
Metadata,
|
|
13
|
+
NavigationLink,
|
|
14
|
+
Price,
|
|
15
|
+
)
|
|
16
|
+
from opds_client.models.feed import (
|
|
17
|
+
Collection,
|
|
18
|
+
Facet,
|
|
19
|
+
Group,
|
|
20
|
+
OPDSFeed,
|
|
21
|
+
)
|
|
22
|
+
from opds_client.models.publication import (
|
|
23
|
+
AcquisitionLink,
|
|
24
|
+
BelongsTo,
|
|
25
|
+
Contributor,
|
|
26
|
+
OPDSPublication,
|
|
27
|
+
Publication,
|
|
28
|
+
Series,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Link",
|
|
33
|
+
"Metadata",
|
|
34
|
+
"NavigationLink",
|
|
35
|
+
"ImageLink",
|
|
36
|
+
"FacetLink",
|
|
37
|
+
"Price",
|
|
38
|
+
"IndirectAcquisition",
|
|
39
|
+
"Contributor",
|
|
40
|
+
"Series",
|
|
41
|
+
"BelongsTo",
|
|
42
|
+
"AcquisitionLink",
|
|
43
|
+
"Publication",
|
|
44
|
+
"OPDSPublication",
|
|
45
|
+
"Collection",
|
|
46
|
+
"Facet",
|
|
47
|
+
"Group",
|
|
48
|
+
"OPDSFeed",
|
|
49
|
+
"AcquisitionFlow",
|
|
50
|
+
"AcquisitionManager",
|
|
51
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Acquisition models for OPDS 2.0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from opds_client.models.base import Link
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AcquisitionFlow(BaseModel):
|
|
13
|
+
"""Represents an acquisition flow with multiple steps."""
|
|
14
|
+
|
|
15
|
+
link: Link
|
|
16
|
+
indirect_acquisition: Optional[list["AcquisitionFlow"]] = Field(
|
|
17
|
+
default=None, alias="indirectAcquisition"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
21
|
+
|
|
22
|
+
def get_final_link(self) -> Link:
|
|
23
|
+
"""Get the final link in the acquisition chain.
|
|
24
|
+
|
|
25
|
+
Returns the deepest link in the indirect acquisition chain.
|
|
26
|
+
If there are no indirect acquisitions, returns the current link.
|
|
27
|
+
"""
|
|
28
|
+
if not self.indirect_acquisition:
|
|
29
|
+
return self.link
|
|
30
|
+
|
|
31
|
+
current = self
|
|
32
|
+
while current.indirect_acquisition:
|
|
33
|
+
current = current.indirect_acquisition[0]
|
|
34
|
+
return current.link
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AcquisitionManager:
|
|
38
|
+
"""Manager for handling acquisition flows."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, link: Link):
|
|
41
|
+
"""Initialize with an acquisition link."""
|
|
42
|
+
self.link = link
|
|
43
|
+
self.flows: list[AcquisitionFlow] = []
|
|
44
|
+
|
|
45
|
+
def add_flow(self, flow: AcquisitionFlow) -> None:
|
|
46
|
+
"""Add an acquisition flow."""
|
|
47
|
+
self.flows.append(flow)
|
|
48
|
+
|
|
49
|
+
def get_open_access_link(self) -> Optional[Link]:
|
|
50
|
+
"""Get an open access link if available."""
|
|
51
|
+
if self.link.rel == "open-access":
|
|
52
|
+
return self.link
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def get_buy_link(self) -> Optional[Link]:
|
|
56
|
+
"""Get a buy link if available."""
|
|
57
|
+
if self.link.rel == "buy" or (
|
|
58
|
+
isinstance(self.link.rel, list) and "buy" in self.link.rel
|
|
59
|
+
):
|
|
60
|
+
return self.link
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def get_borrow_link(self) -> Optional[Link]:
|
|
64
|
+
"""Get a borrow link if available."""
|
|
65
|
+
if self.link.rel == "borrow" or (
|
|
66
|
+
isinstance(self.link.rel, list) and "borrow" in self.link.rel
|
|
67
|
+
):
|
|
68
|
+
return self.link
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def get_subscribe_link(self) -> Optional[Link]:
|
|
72
|
+
"""Get a subscribe link if available."""
|
|
73
|
+
if self.link.rel == "subscribe" or (
|
|
74
|
+
isinstance(self.link.rel, list) and "subscribe" in self.link.rel
|
|
75
|
+
):
|
|
76
|
+
return self.link
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def get_download_link(self) -> Optional[Link]:
|
|
80
|
+
"""Get a download link if available."""
|
|
81
|
+
if self.link.rel == "download" or (
|
|
82
|
+
isinstance(self.link.rel, list) and "download" in self.link.rel
|
|
83
|
+
):
|
|
84
|
+
return self.link
|
|
85
|
+
return None
|