bibleql 0.1.0__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.
- bibleql/__init__.py +67 -0
- bibleql/_version.py +1 -0
- bibleql/client.py +357 -0
- bibleql/errors.py +52 -0
- bibleql/models.py +309 -0
- bibleql/py.typed +0 -0
- bibleql/queries.py +353 -0
- bibleql-0.1.0.dist-info/METADATA +271 -0
- bibleql-0.1.0.dist-info/RECORD +11 -0
- bibleql-0.1.0.dist-info/WHEEL +4 -0
- bibleql-0.1.0.dist-info/licenses/LICENSE +21 -0
bibleql/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Python client for the BibleQL GraphQL API."""
|
|
2
|
+
|
|
3
|
+
from bibleql._version import __version__
|
|
4
|
+
from bibleql.client import Client
|
|
5
|
+
from bibleql.errors import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BibleQLError,
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
NetworkError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
QueryError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
RequestTimeoutError,
|
|
15
|
+
ServerError,
|
|
16
|
+
)
|
|
17
|
+
from bibleql.models import (
|
|
18
|
+
Book,
|
|
19
|
+
BookCount,
|
|
20
|
+
Chapter,
|
|
21
|
+
ConcordanceEntry,
|
|
22
|
+
ConcordanceIndexEntry,
|
|
23
|
+
ConcordanceOccurrence,
|
|
24
|
+
ConcordancePage,
|
|
25
|
+
Language,
|
|
26
|
+
LocalizedBook,
|
|
27
|
+
Passage,
|
|
28
|
+
SemanticSearchResult,
|
|
29
|
+
StrongsEntry,
|
|
30
|
+
Testament,
|
|
31
|
+
TestamentCount,
|
|
32
|
+
Translation,
|
|
33
|
+
Verse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"Client",
|
|
39
|
+
# Errors
|
|
40
|
+
"APIError",
|
|
41
|
+
"AuthenticationError",
|
|
42
|
+
"BibleQLError",
|
|
43
|
+
"ConfigurationError",
|
|
44
|
+
"NetworkError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"QueryError",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"RequestTimeoutError",
|
|
49
|
+
"ServerError",
|
|
50
|
+
# Models
|
|
51
|
+
"Book",
|
|
52
|
+
"BookCount",
|
|
53
|
+
"Chapter",
|
|
54
|
+
"ConcordanceEntry",
|
|
55
|
+
"ConcordanceIndexEntry",
|
|
56
|
+
"ConcordanceOccurrence",
|
|
57
|
+
"ConcordancePage",
|
|
58
|
+
"Language",
|
|
59
|
+
"LocalizedBook",
|
|
60
|
+
"Passage",
|
|
61
|
+
"SemanticSearchResult",
|
|
62
|
+
"StrongsEntry",
|
|
63
|
+
"Testament",
|
|
64
|
+
"TestamentCount",
|
|
65
|
+
"Translation",
|
|
66
|
+
"Verse",
|
|
67
|
+
]
|
bibleql/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bibleql/client.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""HTTP client for the BibleQL GraphQL API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator, Sequence
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from bibleql import queries
|
|
14
|
+
from bibleql._version import __version__
|
|
15
|
+
from bibleql.errors import (
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
NetworkError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
QueryError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
RequestTimeoutError,
|
|
24
|
+
ServerError,
|
|
25
|
+
)
|
|
26
|
+
from bibleql.models import (
|
|
27
|
+
Book,
|
|
28
|
+
ConcordanceIndexEntry,
|
|
29
|
+
ConcordanceOccurrence,
|
|
30
|
+
ConcordancePage,
|
|
31
|
+
Language,
|
|
32
|
+
LocalizedBook,
|
|
33
|
+
Passage,
|
|
34
|
+
SemanticSearchResult,
|
|
35
|
+
Testament,
|
|
36
|
+
Translation,
|
|
37
|
+
Verse,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
DEFAULT_API_URL = "https://bibleql.org/graphql"
|
|
41
|
+
DEFAULT_TRANSLATION = "eng-web"
|
|
42
|
+
DEFAULT_TIMEOUT = 30.0
|
|
43
|
+
API_KEY_ENV_VAR = "BIBLEQL_API_KEY"
|
|
44
|
+
|
|
45
|
+
# The API limits query complexity to 300, and a concordance page costs about first × 28 / 5,
|
|
46
|
+
# so 53 is the largest page that fits. Measured against the live API on 2026-10-18.
|
|
47
|
+
MAX_CONCORDANCE_PAGE_SIZE = 50
|
|
48
|
+
|
|
49
|
+
# Phrases the API uses in GraphQL error messages for missing records:
|
|
50
|
+
# "Book 'XYZ' not found" (PassageLookup) and "Couldn't find Translation" (Rails RecordNotFound).
|
|
51
|
+
_NOT_FOUND_MARKERS = ("not found", "couldn't find")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Client:
|
|
55
|
+
"""Client for the BibleQL GraphQL API.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
import bibleql
|
|
60
|
+
|
|
61
|
+
with bibleql.Client(api_key="bql_live_...") as client:
|
|
62
|
+
passage = client.passage("John 3:16")
|
|
63
|
+
print(passage.text)
|
|
64
|
+
|
|
65
|
+
If ``api_key`` is omitted, it is read from the ``BIBLEQL_API_KEY`` environment variable.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
default_translation: str = DEFAULT_TRANSLATION,
|
|
73
|
+
api_url: str = DEFAULT_API_URL,
|
|
74
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
75
|
+
) -> None:
|
|
76
|
+
api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
77
|
+
if not api_key:
|
|
78
|
+
raise ConfigurationError(
|
|
79
|
+
f"api_key is required. Pass it to Client(api_key=...) or set {API_KEY_ENV_VAR}."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.default_translation = default_translation
|
|
83
|
+
self.api_url = api_url
|
|
84
|
+
self._http = httpx.Client(
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
headers={
|
|
87
|
+
"Authorization": f"Bearer {api_key}",
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": f"bibleql-python/{__version__}",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
# Never include the API key here: reprs end up in logs and tracebacks.
|
|
95
|
+
return f"Client(api_url={self.api_url!r}, default_translation={self.default_translation!r})"
|
|
96
|
+
|
|
97
|
+
# --- Lifecycle -------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""Close the underlying HTTP connection pool."""
|
|
101
|
+
self._http.close()
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> Client:
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(
|
|
107
|
+
self,
|
|
108
|
+
exc_type: type[BaseException] | None,
|
|
109
|
+
exc: BaseException | None,
|
|
110
|
+
tb: TracebackType | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
# --- Translations, books, languages ----------------------------------------
|
|
115
|
+
|
|
116
|
+
def translations(self) -> list[Translation]:
|
|
117
|
+
"""List all available Bible translations."""
|
|
118
|
+
data = self._execute(queries.translations())
|
|
119
|
+
return [Translation.from_api(t) for t in data["translations"]]
|
|
120
|
+
|
|
121
|
+
def translation(self, identifier: str) -> Translation:
|
|
122
|
+
"""Get one translation, including its books with localized names."""
|
|
123
|
+
data = self._execute(queries.translation(identifier))
|
|
124
|
+
return Translation.from_api(data["translation"])
|
|
125
|
+
|
|
126
|
+
def books(self) -> list[Book]:
|
|
127
|
+
"""List the 66 canonical books in order."""
|
|
128
|
+
data = self._execute(queries.books())
|
|
129
|
+
return [Book.from_api(b) for b in data["books"]]
|
|
130
|
+
|
|
131
|
+
def languages(self) -> list[Language]:
|
|
132
|
+
"""List every language that has at least one translation."""
|
|
133
|
+
data = self._execute(queries.languages())
|
|
134
|
+
return [Language.from_api(lang) for lang in data["languages"]]
|
|
135
|
+
|
|
136
|
+
# --- Passages and verses ---------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def passage(self, reference: str, *, translation: str | None = None) -> Passage:
|
|
139
|
+
"""Look up a passage such as ``"John 3:16"``, ``"John 3:16-18"`` or ``"Mateo 28:18-20"``."""
|
|
140
|
+
body = queries.passage(reference, translation=self._translation(translation))
|
|
141
|
+
return Passage.from_api(self._execute(body)["passage"])
|
|
142
|
+
|
|
143
|
+
def chapter(self, book: str, chapter: int, *, translation: str | None = None) -> list[Verse]:
|
|
144
|
+
"""Get every verse in a chapter. ``book`` is a book id (``"MAT"``) or localized name."""
|
|
145
|
+
body = queries.chapter(book, chapter, translation=self._translation(translation))
|
|
146
|
+
return [Verse.from_api(v) for v in self._execute(body)["chapter"]]
|
|
147
|
+
|
|
148
|
+
def verse(
|
|
149
|
+
self, book: str, chapter: int, verse: int, *, translation: str | None = None
|
|
150
|
+
) -> Verse:
|
|
151
|
+
"""Get a single verse."""
|
|
152
|
+
body = queries.verse(book, chapter, verse, translation=self._translation(translation))
|
|
153
|
+
return Verse.from_api(self._execute(body)["verse"])
|
|
154
|
+
|
|
155
|
+
def random_verse(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
translation: str | None = None,
|
|
159
|
+
testament: str | None = None,
|
|
160
|
+
books: str | Sequence[str] | None = None,
|
|
161
|
+
) -> Verse:
|
|
162
|
+
"""Get a random verse.
|
|
163
|
+
|
|
164
|
+
``testament`` is ``"OT"`` or ``"NT"``. ``books`` is a list of book ids or
|
|
165
|
+
localized names (``["PSA", "PRO"]``) or a comma-separated string (``"PSA,PRO"``).
|
|
166
|
+
"""
|
|
167
|
+
if books is not None and not isinstance(books, str):
|
|
168
|
+
books = ",".join(books)
|
|
169
|
+
body = queries.random_verse(
|
|
170
|
+
translation=self._translation(translation), testament=testament, books=books
|
|
171
|
+
)
|
|
172
|
+
return Verse.from_api(self._execute(body)["randomVerse"])
|
|
173
|
+
|
|
174
|
+
def verse_of_the_day(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
translation: str | None = None,
|
|
178
|
+
date: datetime.date | str | None = None,
|
|
179
|
+
) -> Passage:
|
|
180
|
+
"""Get the verse of the day. ``date`` defaults to today (server time)."""
|
|
181
|
+
if isinstance(date, datetime.date):
|
|
182
|
+
date = date.isoformat()
|
|
183
|
+
body = queries.verse_of_the_day(translation=self._translation(translation), date=date)
|
|
184
|
+
return Passage.from_api(self._execute(body)["verseOfTheDay"])
|
|
185
|
+
|
|
186
|
+
def bible_index(self, *, translation: str | None = None) -> list[LocalizedBook]:
|
|
187
|
+
"""Get every book with its chapters and verse counts."""
|
|
188
|
+
body = queries.bible_index(translation=self._translation(translation))
|
|
189
|
+
return [LocalizedBook.from_api(b) for b in self._execute(body)["bibleIndex"]]
|
|
190
|
+
|
|
191
|
+
# --- Search ----------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def search(
|
|
194
|
+
self, query: str, *, translation: str | None = None, limit: int | None = None
|
|
195
|
+
) -> list[Verse]:
|
|
196
|
+
"""Case-insensitive substring search (no stemming). The API caps ``limit`` at 100."""
|
|
197
|
+
body = queries.search(query, translation=self._translation(translation), limit=limit)
|
|
198
|
+
return [Verse.from_api(v) for v in self._execute(body)["search"]]
|
|
199
|
+
|
|
200
|
+
def semantic_search(
|
|
201
|
+
self, query: str, *, translation: str | None = None, limit: int | None = None
|
|
202
|
+
) -> list[SemanticSearchResult]:
|
|
203
|
+
"""Search by meaning using AI embeddings. The API caps ``limit`` at 50.
|
|
204
|
+
|
|
205
|
+
Unlike the other methods, this does **not** fall back to ``default_translation``:
|
|
206
|
+
only some translations have embeddings, so when ``translation`` is omitted the
|
|
207
|
+
API's own default (currently ``spa-rv1909``) is used.
|
|
208
|
+
"""
|
|
209
|
+
body = queries.semantic_search(query, translation=translation, limit=limit)
|
|
210
|
+
return [SemanticSearchResult.from_api(r) for r in self._execute(body)["semanticSearch"]]
|
|
211
|
+
|
|
212
|
+
# --- Concordance -----------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def concordance(
|
|
215
|
+
self,
|
|
216
|
+
word: str,
|
|
217
|
+
*,
|
|
218
|
+
translation: str | None = None,
|
|
219
|
+
book: str | None = None,
|
|
220
|
+
testament: Testament | str | None = None,
|
|
221
|
+
first: int | None = None,
|
|
222
|
+
after: str | None = None,
|
|
223
|
+
) -> ConcordancePage:
|
|
224
|
+
"""Get one page of every occurrence of ``word``, in canonical order.
|
|
225
|
+
|
|
226
|
+
Pass ``after=page.end_cursor`` to fetch the next page, or use
|
|
227
|
+
:meth:`iter_concordance` to walk all pages. ``first`` defaults to 25 on the API.
|
|
228
|
+
Keep it at or below :data:`MAX_CONCORDANCE_PAGE_SIZE`: larger pages exceed the
|
|
229
|
+
API's query complexity limit and raise :class:`QueryError`.
|
|
230
|
+
"""
|
|
231
|
+
body = queries.concordance(
|
|
232
|
+
word,
|
|
233
|
+
translation=self._translation(translation),
|
|
234
|
+
book=book,
|
|
235
|
+
testament=_testament_value(testament),
|
|
236
|
+
first=first,
|
|
237
|
+
after=after,
|
|
238
|
+
)
|
|
239
|
+
return ConcordancePage.from_api(self._execute(body)["concordance"])
|
|
240
|
+
|
|
241
|
+
def iter_concordance(
|
|
242
|
+
self,
|
|
243
|
+
word: str,
|
|
244
|
+
*,
|
|
245
|
+
translation: str | None = None,
|
|
246
|
+
book: str | None = None,
|
|
247
|
+
testament: Testament | str | None = None,
|
|
248
|
+
page_size: int = MAX_CONCORDANCE_PAGE_SIZE,
|
|
249
|
+
) -> Iterator[ConcordanceOccurrence]:
|
|
250
|
+
"""Yield every occurrence of ``word``, fetching further pages lazily as needed."""
|
|
251
|
+
after: str | None = None
|
|
252
|
+
while True:
|
|
253
|
+
page = self.concordance(
|
|
254
|
+
word,
|
|
255
|
+
translation=translation,
|
|
256
|
+
book=book,
|
|
257
|
+
testament=testament,
|
|
258
|
+
first=page_size,
|
|
259
|
+
after=after,
|
|
260
|
+
)
|
|
261
|
+
yield from page.occurrences
|
|
262
|
+
if not page.has_next_page or page.end_cursor is None:
|
|
263
|
+
return
|
|
264
|
+
after = page.end_cursor
|
|
265
|
+
|
|
266
|
+
def concordance_index(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
translation: str | None = None,
|
|
270
|
+
prefix: str | None = None,
|
|
271
|
+
min_occurrences: int | None = None,
|
|
272
|
+
first: int | None = None,
|
|
273
|
+
) -> list[ConcordanceIndexEntry]:
|
|
274
|
+
"""Alphabetical word-frequency index. ``first`` is clamped by the API to 1..200."""
|
|
275
|
+
body = queries.concordance_index(
|
|
276
|
+
translation=self._translation(translation),
|
|
277
|
+
prefix=prefix,
|
|
278
|
+
min_occurrences=min_occurrences,
|
|
279
|
+
first=first,
|
|
280
|
+
)
|
|
281
|
+
return [ConcordanceIndexEntry.from_api(e) for e in self._execute(body)["concordanceIndex"]]
|
|
282
|
+
|
|
283
|
+
# --- Internals -------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def _translation(self, translation: str | None) -> str:
|
|
286
|
+
return translation or self.default_translation
|
|
287
|
+
|
|
288
|
+
def _execute(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
289
|
+
"""POST a GraphQL request and return its ``data``, raising a BibleQLError on failure."""
|
|
290
|
+
try:
|
|
291
|
+
response = self._http.post(self.api_url, json=body)
|
|
292
|
+
except httpx.TimeoutException as e:
|
|
293
|
+
raise RequestTimeoutError(f"Request timed out: {e}") from e
|
|
294
|
+
except httpx.RequestError as e:
|
|
295
|
+
raise NetworkError(f"Connection failed: {e}") from e
|
|
296
|
+
|
|
297
|
+
_raise_for_status(response)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
payload = response.json()
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
raise APIError(
|
|
303
|
+
"Response was not valid JSON", status=response.status_code, body=response.text
|
|
304
|
+
) from e
|
|
305
|
+
|
|
306
|
+
errors = payload.get("errors")
|
|
307
|
+
if errors:
|
|
308
|
+
_raise_for_graphql_errors(errors)
|
|
309
|
+
|
|
310
|
+
return payload["data"]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
314
|
+
status = response.status_code
|
|
315
|
+
if 200 <= status < 300:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
error_class: type[APIError]
|
|
319
|
+
if status == 401:
|
|
320
|
+
error_class = AuthenticationError
|
|
321
|
+
elif status == 429:
|
|
322
|
+
error_class = RateLimitError
|
|
323
|
+
elif status >= 500:
|
|
324
|
+
error_class = ServerError
|
|
325
|
+
else:
|
|
326
|
+
error_class = APIError
|
|
327
|
+
|
|
328
|
+
# The API explains HTTP errors in a GraphQL-style body, e.g.
|
|
329
|
+
# 401 → {"errors": [{"message": "Invalid or missing API key"}]}. Surface it when present.
|
|
330
|
+
message = f"HTTP {status}"
|
|
331
|
+
try:
|
|
332
|
+
detail = _join_messages(response.json().get("errors") or [])
|
|
333
|
+
except (ValueError, AttributeError):
|
|
334
|
+
detail = ""
|
|
335
|
+
if detail:
|
|
336
|
+
message = f"{message}: {detail}"
|
|
337
|
+
raise error_class(message, status=status, body=response.text)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _join_messages(errors: list[dict[str, Any]]) -> str:
|
|
341
|
+
return "; ".join(str(error.get("message", "")) for error in errors)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _raise_for_graphql_errors(errors: list[dict[str, Any]]) -> None:
|
|
345
|
+
message = _join_messages(errors)
|
|
346
|
+
if any(marker in message.lower() for marker in _NOT_FOUND_MARKERS):
|
|
347
|
+
raise NotFoundError(message, errors=errors)
|
|
348
|
+
raise QueryError(message, errors=errors)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _testament_value(testament: Testament | str | None) -> str | None:
|
|
352
|
+
"""Accept ``Testament.OLD``, ``"OLD"`` or ``"old"`` and return the enum value to send."""
|
|
353
|
+
if testament is None:
|
|
354
|
+
return None
|
|
355
|
+
if isinstance(testament, Testament):
|
|
356
|
+
return testament.value
|
|
357
|
+
return Testament(testament.upper()).value
|
bibleql/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exceptions raised by the BibleQL client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BibleQLError(Exception):
|
|
7
|
+
"""Base class for every error raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(BibleQLError):
|
|
11
|
+
"""The client is misconfigured (e.g. missing API key)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NetworkError(BibleQLError):
|
|
15
|
+
"""The request never got a response (DNS failure, connection refused, ...)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequestTimeoutError(NetworkError):
|
|
19
|
+
"""The request took longer than the configured timeout."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(BibleQLError):
|
|
23
|
+
"""The server answered with a non-2xx HTTP status."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, status: int | None = None, body: str | None = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.body = body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(APIError):
|
|
32
|
+
"""HTTP 401: the API key is missing or invalid."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RateLimitError(APIError):
|
|
36
|
+
"""HTTP 429: too many requests."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServerError(APIError):
|
|
40
|
+
"""HTTP 5xx: something went wrong on the server."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QueryError(BibleQLError):
|
|
44
|
+
"""The GraphQL response contained an ``errors`` array."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, errors: list[dict[str, Any]] | None = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.errors = errors or []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFoundError(QueryError):
|
|
52
|
+
"""A GraphQL error said the requested resource was not found."""
|