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