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.
@@ -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