|
4 | 4 | """ |
5 | 5 |
|
6 | 6 | from abc import ABC, abstractmethod |
7 | | -from dataclasses import dataclass |
8 | | -import functools |
| 7 | +from collections.abc import Mapping |
9 | 8 | from typing import List, Optional |
10 | 9 | from pydantic import BaseModel |
11 | 10 |
|
@@ -44,102 +43,90 @@ def to_publication(self) -> Publication: |
44 | 43 | ) |
45 | 44 |
|
46 | 45 |
|
| 46 | +class Search(BaseModel, Mapping): |
| 47 | + query: str |
| 48 | + limit: int = 50 |
| 49 | + offset: int = 0 |
| 50 | + sort: Optional[str] = None |
| 51 | + |
| 52 | + def __iter__(self): |
| 53 | + """Allows **Search(...) to unpack into a dict for DataProvider.search(**s)""" |
| 54 | + return iter(self.model_dump()) |
| 55 | + |
| 56 | + def __getitem__(self, item): |
| 57 | + return getattr(self, item) |
| 58 | + |
| 59 | + def __len__(self): |
| 60 | + return len(self.model_fields) |
| 61 | + |
| 62 | + @property |
| 63 | + def params(self) -> dict[str, str]: |
| 64 | + d = self.model_dump(exclude_none=True) |
| 65 | + return {k: str(v) for k, v in d.items()} |
| 66 | + |
| 67 | + |
| 68 | +class SearchResponse(BaseModel): |
| 69 | + """Response from a search query.""" |
| 70 | + provider: type["DataProvider"] |
| 71 | + search: Search |
| 72 | + records: List[DataProviderRecord] |
| 73 | + total: int |
| 74 | + |
| 75 | + @property |
| 76 | + def params(self) -> dict[str, str]: |
| 77 | + return self.search.params |
| 78 | + |
| 79 | + @property |
| 80 | + def page(self) -> int: |
| 81 | + """Calculate current page number based on offset and limit.""" |
| 82 | + if self.search.limit <= 0: return 1 |
| 83 | + return (self.search.offset // self.search.limit) + 1 |
| 84 | + |
| 85 | + @property |
| 86 | + def last_page(self) -> int: |
| 87 | + """Calculate last page number based on total and limit.""" |
| 88 | + if self.search.limit <= 0 or self.total == 0: return 1 |
| 89 | + return (self.total + self.search.limit - 1) // self.search.limit |
| 90 | + |
| 91 | + @property |
| 92 | + def has_more(self) -> bool: |
| 93 | + """Determine if there are more results beyond the current page.""" |
| 94 | + return (self.search.offset + self.search.limit) < self.total |
| 95 | + |
| 96 | + |
47 | 97 | class DataProvider(ABC): |
48 | 98 | """Abstract base class for OPDS 2.0 data providers. |
49 | 99 |
|
50 | 100 | Consumers of this library should extend this class to provide |
51 | 101 | their own implementation for searching and retrieving publications. |
52 | | -
|
53 | | - Example: |
54 | | - class MyDataProvider(DataProvider): |
55 | | - def search(self, query: str, limit: int = 50, offset: int = 0) -> List[Publication]: |
56 | | - # Implement search logic |
57 | | - results = my_search_function(query, limit, offset) |
58 | | - return [self._to_publication(item) for item in results] |
59 | 102 | """ |
60 | 103 |
|
61 | 104 | TITLE: str = "Generic OPDS Service" |
62 | | - |
63 | 105 | BASE_URL: str = "http://localhost" |
64 | | - """The base url for the data provider.""" |
65 | | - |
66 | 106 | SEARCH_URL: str = "/opds/search{?query}" |
67 | | - """The relative url template for search queries.""" |
68 | | - |
69 | | - @dataclass |
70 | | - class SearchResponse: |
71 | | - """Response from a search query.""" |
72 | | - provider: 'DataProvider | type[DataProvider]' |
73 | | - query: str |
74 | | - limit: int |
75 | | - offset: int |
76 | | - sort: Optional[str] |
77 | | - records: List[DataProviderRecord] |
78 | | - total: int |
79 | | - title: Optional[str] = None |
80 | | - |
81 | | - def get_search_url(self, **kwargs: str) -> str: |
82 | | - base_url = self.provider.SEARCH_URL.replace("{?query}", "") |
83 | | - if base_url.startswith("/"): |
84 | | - base_url = self.provider.BASE_URL.rstrip('/') + base_url |
85 | | - return build_url(base_url, params=self.params | kwargs) |
86 | | - |
87 | | - @functools.cached_property |
88 | | - def params(self) -> dict: |
89 | | - p: dict[str, str] = {} |
90 | | - if self.query: |
91 | | - p["query"] = self.query |
92 | | - if self.limit: |
93 | | - p["limit"] = str(self.limit) |
94 | | - if self.page > 1: |
95 | | - p["page"] = str(self.page) |
96 | | - if self.sort: |
97 | | - p["sort"] = self.sort |
98 | | - return p |
99 | | - |
100 | | - @property |
101 | | - def page(self) -> int: |
102 | | - """Calculate current page number based on offset and limit.""" |
103 | | - return (self.offset // self.limit) + 1 if self.limit else 1 |
104 | | - |
105 | | - @property |
106 | | - def last_page(self) -> int: |
107 | | - """Calculate last page number based on total and limit.""" |
108 | | - return (self.total + self.limit - 1) // self.limit |
109 | | - |
110 | | - @property |
111 | | - def has_more(self) -> bool: |
112 | | - """Determine if there are more results beyond the current page.""" |
113 | | - return (self.offset + self.limit) < self.total |
114 | | - |
115 | | - @staticmethod |
116 | | - @abstractmethod |
| 107 | + |
| 108 | + @classmethod |
117 | 109 | def search( |
| 110 | + cls, |
118 | 111 | query: str, |
119 | 112 | limit: int = 50, |
120 | 113 | offset: int = 0, |
121 | 114 | sort: Optional[str] = None, |
122 | | - title: Optional[str] = None, |
123 | | - ) -> 'DataProvider.SearchResponse': |
| 115 | + ) -> SearchResponse: |
124 | 116 | """Search for publications matching the query. |
125 | 117 |
|
126 | 118 | Args: |
127 | 119 | query: Search query string |
128 | 120 | limit: Maximum number of results to return (default: 50) |
129 | 121 | offset: Offset for pagination (default: 0) |
130 | 122 | sort: Optional sorting parameter |
131 | | - title: Title to pass along to Catalog.create |
132 | 123 |
|
133 | 124 | Returns: |
134 | 125 | SearchResponse object containing search results |
135 | 126 | """ |
136 | | - return DataProvider.SearchResponse( |
137 | | - DataProvider, |
| 127 | + return SearchResponse( |
| 128 | + provider=cls, |
| 129 | + search=Search(query=query, limit=limit, offset=offset, sort=sort), |
138 | 130 | records=[], |
139 | 131 | total=0, |
140 | | - query=query, |
141 | | - limit=limit, |
142 | | - offset=offset, |
143 | | - sort=sort, |
144 | | - title=title, |
145 | 132 | ) |
0 commit comments