pyolivetree 2.0.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.
- pyolivetree/__init__.py +37 -0
- pyolivetree/__main__.py +3 -0
- pyolivetree/_http.py +116 -0
- pyolivetree/async_client.py +131 -0
- pyolivetree/cli.py +42 -0
- pyolivetree/client.py +273 -0
- pyolivetree/constants.py +101 -0
- pyolivetree/exceptions.py +35 -0
- pyolivetree/notes.py +174 -0
- pyolivetree/py.typed +0 -0
- pyolivetree/resources/__init__.py +27 -0
- pyolivetree/resources/_base.py +20 -0
- pyolivetree/resources/account.py +117 -0
- pyolivetree/resources/ai.py +41 -0
- pyolivetree/resources/firebase.py +24 -0
- pyolivetree/resources/media.py +44 -0
- pyolivetree/resources/messages.py +46 -0
- pyolivetree/resources/products.py +55 -0
- pyolivetree/resources/reading.py +53 -0
- pyolivetree/resources/store.py +185 -0
- pyolivetree/resources/sync.py +156 -0
- pyolivetree/resources/telemetry.py +63 -0
- pyolivetree/resources/web.py +61 -0
- pyolivetree-2.0.0.dist-info/METADATA +168 -0
- pyolivetree-2.0.0.dist-info/RECORD +28 -0
- pyolivetree-2.0.0.dist-info/WHEEL +4 -0
- pyolivetree-2.0.0.dist-info/entry_points.txt +2 -0
- pyolivetree-2.0.0.dist-info/licenses/LICENSE +21 -0
pyolivetree/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Python client for the Olive Tree Bible.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
>>> from pyolivetree import OliveTree # doctest: +SKIP
|
|
5
|
+
>>> with OliveTree() as ot:
|
|
6
|
+
... ot.sync.status()
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from pyolivetree._http import sign_body
|
|
12
|
+
from pyolivetree.async_client import AsyncOliveTree, AsyncOliveTreeClient
|
|
13
|
+
from pyolivetree.client import OliveTree, OliveTreeClient
|
|
14
|
+
from pyolivetree.constants import DataSet
|
|
15
|
+
from pyolivetree.exceptions import OliveTreeAPIError, OliveTreeAuthError, OliveTreeError
|
|
16
|
+
from pyolivetree.notes import annotations, backup, to_markdown, verse_ref
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = version("pyolivetree")
|
|
20
|
+
except PackageNotFoundError: # running from a source checkout
|
|
21
|
+
__version__ = "0.0.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncOliveTree",
|
|
25
|
+
"AsyncOliveTreeClient",
|
|
26
|
+
"DataSet",
|
|
27
|
+
"OliveTree",
|
|
28
|
+
"OliveTreeAPIError",
|
|
29
|
+
"OliveTreeAuthError",
|
|
30
|
+
"OliveTreeClient",
|
|
31
|
+
"OliveTreeError",
|
|
32
|
+
"annotations",
|
|
33
|
+
"backup",
|
|
34
|
+
"sign_body",
|
|
35
|
+
"to_markdown",
|
|
36
|
+
"verse_ref",
|
|
37
|
+
]
|
pyolivetree/__main__.py
ADDED
pyolivetree/_http.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Small helpers: .env loading, request signing and response parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from pyolivetree.constants import HMAC_KEYS
|
|
16
|
+
from pyolivetree.exceptions import OliveTreeAPIError, OliveTreeAuthError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_dotenv(path: str = ".env") -> None:
|
|
20
|
+
"""Load ``KEY=VALUE`` lines into :data:`os.environ`, keeping values already set.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path: File to read. Missing files are ignored.
|
|
24
|
+
"""
|
|
25
|
+
if not os.path.isfile(path):
|
|
26
|
+
return
|
|
27
|
+
for line in Path(path).read_text().splitlines():
|
|
28
|
+
key, sep, val = line.partition("=")
|
|
29
|
+
if sep and not line.lstrip().startswith("#"):
|
|
30
|
+
os.environ.setdefault(key.strip(), val.strip().strip("\"'"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sign_body(body: bytes, profile: str = "telemetry", key: bytes | None = None) -> str:
|
|
34
|
+
"""Compute the ``signature`` header required by signed endpoints.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
body: Exact request body bytes.
|
|
38
|
+
profile: Key name in :data:`~pyolivetree.constants.HMAC_KEYS`
|
|
39
|
+
(``"telemetry"``, ``"ai"``, ``"consent"`` or ``"features"``).
|
|
40
|
+
key: Use this key instead of the profile's key.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Uppercase hex HMAC-SHA512 digest.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
OliveTreeAuthError: If ``profile`` is unknown and no ``key`` is given.
|
|
47
|
+
"""
|
|
48
|
+
if key is None:
|
|
49
|
+
try:
|
|
50
|
+
key = HMAC_KEYS[profile]
|
|
51
|
+
except KeyError:
|
|
52
|
+
raise OliveTreeAuthError(f"Unknown HMAC profile: {profile!r}") from None
|
|
53
|
+
return hmac.new(key, body, hashlib.sha512).hexdigest().upper()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_hmac_key(raw: str | bytes | None) -> bytes | None:
|
|
57
|
+
"""Decode an HMAC key given as hex, base64 or raw bytes.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
raw: Key material, e.g. from ``OLIVE_TREE_HMAC_KEY``. Empty means no override.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Key bytes, or ``None`` if ``raw`` is empty.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If a string is neither hex nor base64.
|
|
67
|
+
"""
|
|
68
|
+
if not raw:
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(raw, bytes):
|
|
71
|
+
return raw
|
|
72
|
+
text = raw.strip()
|
|
73
|
+
try:
|
|
74
|
+
return bytes.fromhex(text)
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
return base64.b64decode(text, validate=True)
|
|
79
|
+
except binascii.Error:
|
|
80
|
+
raise ValueError("OLIVE_TREE_HMAC_KEY must be hex or base64") from None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_response(r: httpx.Response) -> Any:
|
|
84
|
+
"""Decode a response by content type and raise on errors.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
r: The HTTP response.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Parsed JSON, ``str`` for text/XML/HTML, otherwise ``bytes``.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
OliveTreeAuthError: On HTTP 401 or ``{"authenticated": false}``.
|
|
94
|
+
OliveTreeAPIError: On any other HTTP status >= 400.
|
|
95
|
+
"""
|
|
96
|
+
ctype = r.headers.get("content-type", "")
|
|
97
|
+
body: Any = r.content
|
|
98
|
+
if "json" in ctype or r.content[:1] in (b"{", b"["):
|
|
99
|
+
try:
|
|
100
|
+
body = r.json()
|
|
101
|
+
except ValueError:
|
|
102
|
+
body = r.text
|
|
103
|
+
elif ctype.startswith("text/") or "xml" in ctype or "html" in ctype:
|
|
104
|
+
body = r.text
|
|
105
|
+
failed = isinstance(body, dict) and body.get("authenticated") is False
|
|
106
|
+
if r.status_code >= 400 or failed:
|
|
107
|
+
code = body.get("errorCode") if isinstance(body, dict) else None
|
|
108
|
+
msg = body.get("error") if isinstance(body, dict) else str(body)[:200]
|
|
109
|
+
cls = OliveTreeAuthError if failed or r.status_code == 401 else OliveTreeAPIError
|
|
110
|
+
raise cls(
|
|
111
|
+
f"{r.request.method} {r.request.url.path}: HTTP {r.status_code}: {msg}",
|
|
112
|
+
r.status_code,
|
|
113
|
+
code,
|
|
114
|
+
body,
|
|
115
|
+
)
|
|
116
|
+
return body
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The :class:`AsyncOliveTree` client: the same API as :class:`~pyolivetree.OliveTree`, with ``await``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pyolivetree.client import OliveTree
|
|
11
|
+
|
|
12
|
+
#: Endpoint groups mirrored from :class:`~pyolivetree.OliveTree`.
|
|
13
|
+
GROUPS = (
|
|
14
|
+
"account",
|
|
15
|
+
"store",
|
|
16
|
+
"products",
|
|
17
|
+
"sync",
|
|
18
|
+
"ai",
|
|
19
|
+
"reading",
|
|
20
|
+
"media",
|
|
21
|
+
"messages",
|
|
22
|
+
"telemetry",
|
|
23
|
+
"web",
|
|
24
|
+
"firebase",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncGroup:
|
|
29
|
+
"""Async view of one endpoint group. Every method becomes a coroutine.
|
|
30
|
+
|
|
31
|
+
Methods that yield (``sync.pull``, ``store.all_products``) return a list instead.
|
|
32
|
+
The 1.x names with an ``a`` prefix (``ahome``, ``asearch``) also work.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, group: Any) -> None:
|
|
36
|
+
self._group = group
|
|
37
|
+
|
|
38
|
+
def __getattr__(self, name: str) -> Any:
|
|
39
|
+
fn = getattr(self._group, name, None)
|
|
40
|
+
if fn is None and name.startswith("a"):
|
|
41
|
+
fn = getattr(self._group, name[1:], None)
|
|
42
|
+
if fn is None:
|
|
43
|
+
raise AttributeError(name)
|
|
44
|
+
if not callable(fn):
|
|
45
|
+
return fn
|
|
46
|
+
|
|
47
|
+
@functools.wraps(fn)
|
|
48
|
+
async def call(*args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
if inspect.isgeneratorfunction(fn):
|
|
50
|
+
return await asyncio.to_thread(lambda: list(fn(*args, **kwargs)))
|
|
51
|
+
return await asyncio.to_thread(fn, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
return call
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncOliveTree:
|
|
57
|
+
"""Async client. Takes the same arguments as :class:`~pyolivetree.OliveTree`.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> async with AsyncOliveTree() as ot: # doctest: +SKIP
|
|
61
|
+
... home = await ot.store.home()
|
|
62
|
+
... notes = await ot.sync.pull("annotations")
|
|
63
|
+
|
|
64
|
+
NOTE: each call runs the synchronous client in a worker thread (``asyncio.to_thread``), so
|
|
65
|
+
concurrency is limited by the default thread pool and calls share one connection pool and token.
|
|
66
|
+
Upgrade path if that becomes a bottleneck: a native ``httpx.AsyncClient`` transport.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
70
|
+
#: The wrapped synchronous client (token, device id, settings live here).
|
|
71
|
+
self.client = OliveTree(*args, **kwargs)
|
|
72
|
+
for name in GROUPS:
|
|
73
|
+
setattr(self, name, AsyncGroup(getattr(self.client, name)))
|
|
74
|
+
|
|
75
|
+
def __getattr__(self, name: str) -> Any:
|
|
76
|
+
# Settings such as token, device_id and app_fields come from the wrapped client.
|
|
77
|
+
return getattr(self.client, name)
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> AsyncOliveTree:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
83
|
+
await self.aclose()
|
|
84
|
+
|
|
85
|
+
async def aclose(self) -> None:
|
|
86
|
+
"""Close the underlying HTTP connection pool."""
|
|
87
|
+
await asyncio.to_thread(self.client.close)
|
|
88
|
+
|
|
89
|
+
async def login(self) -> dict:
|
|
90
|
+
"""Log in. See :meth:`OliveTree.login`."""
|
|
91
|
+
return await asyncio.to_thread(self.client.login)
|
|
92
|
+
|
|
93
|
+
async def refresh(self) -> dict:
|
|
94
|
+
"""Renew the access token. See :meth:`OliveTree.refresh`."""
|
|
95
|
+
return await asyncio.to_thread(self.client.refresh)
|
|
96
|
+
|
|
97
|
+
async def ensure_auth(self) -> None:
|
|
98
|
+
"""Log in now if there is no token yet."""
|
|
99
|
+
await asyncio.to_thread(self.client.ensure_auth)
|
|
100
|
+
|
|
101
|
+
async def backup(self, *args: Any, **kwargs: Any) -> dict[str, int]:
|
|
102
|
+
"""Back up sync data to SQLite. See :meth:`OliveTree.backup`."""
|
|
103
|
+
return await asyncio.to_thread(self.client.backup, *args, **kwargs)
|
|
104
|
+
|
|
105
|
+
async def notes(self, *args: Any, **kwargs: Any) -> list[dict]:
|
|
106
|
+
"""Your notes. See :meth:`OliveTree.notes`."""
|
|
107
|
+
return await asyncio.to_thread(self.client.notes, *args, **kwargs)
|
|
108
|
+
|
|
109
|
+
async def highlights(self, *args: Any, **kwargs: Any) -> list[dict]:
|
|
110
|
+
"""Your highlights. See :meth:`OliveTree.highlights`."""
|
|
111
|
+
return await asyncio.to_thread(self.client.highlights, *args, **kwargs)
|
|
112
|
+
|
|
113
|
+
async def clips(self, *args: Any, **kwargs: Any) -> list[dict]:
|
|
114
|
+
"""Saved verse text. See :meth:`OliveTree.clips`."""
|
|
115
|
+
return await asyncio.to_thread(self.client.clips, *args, **kwargs)
|
|
116
|
+
|
|
117
|
+
async def status(self) -> dict:
|
|
118
|
+
"""Sync status. See :meth:`OliveTree.status`."""
|
|
119
|
+
return await asyncio.to_thread(self.client.status)
|
|
120
|
+
|
|
121
|
+
async def library(self) -> list[dict]:
|
|
122
|
+
"""Every product you own. See :meth:`OliveTree.library`."""
|
|
123
|
+
return await asyncio.to_thread(self.client.library)
|
|
124
|
+
|
|
125
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
126
|
+
"""Send one request. Same arguments as :meth:`OliveTree.request`."""
|
|
127
|
+
return await asyncio.to_thread(self.client.request, method, url, **kwargs)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
#: Name used by pyolivetree 1.x.
|
|
131
|
+
AsyncOliveTreeClient = AsyncOliveTree
|
pyolivetree/cli.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Command line: ``pyolivetree backup|notes|highlights|clips|status|library``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pyolivetree.client import OliveTree
|
|
10
|
+
from pyolivetree.exceptions import OliveTreeError
|
|
11
|
+
from pyolivetree.notes import to_markdown
|
|
12
|
+
|
|
13
|
+
COMMANDS = ("backup", "notes", "highlights", "clips", "status", "library")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> None:
|
|
17
|
+
"""Run the command line. ``argv`` defaults to ``sys.argv[1:]``.
|
|
18
|
+
|
|
19
|
+
``notes``, ``highlights`` and ``clips`` print Markdown from the local backup (add ``--refresh``
|
|
20
|
+
to sync first). ``backup``, ``status`` and ``library`` print JSON.
|
|
21
|
+
"""
|
|
22
|
+
p = argparse.ArgumentParser(prog="pyolivetree", description="Back up and export your Olive Tree notes.")
|
|
23
|
+
p.add_argument("command", choices=COMMANDS)
|
|
24
|
+
p.add_argument("--db", default="olivetree.db", help="local backup file (default: olivetree.db)")
|
|
25
|
+
p.add_argument("--out", help="write output to this file instead of stdout")
|
|
26
|
+
p.add_argument("--refresh", action="store_true", help="sync changes before exporting")
|
|
27
|
+
a = p.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
text = _run(a)
|
|
31
|
+
except OliveTreeError as e:
|
|
32
|
+
p.exit(1, f"pyolivetree: error: {e}\n")
|
|
33
|
+
Path(a.out).write_text(text) if a.out else print(text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run(a: argparse.Namespace) -> str:
|
|
37
|
+
with OliveTree() as ot:
|
|
38
|
+
if a.command in ("notes", "highlights", "clips"):
|
|
39
|
+
return to_markdown(getattr(ot, a.command)(a.db, refresh=a.refresh))
|
|
40
|
+
if a.command == "backup":
|
|
41
|
+
return json.dumps(ot.backup(a.db), indent=2)
|
|
42
|
+
return json.dumps(getattr(ot, a.command)(), indent=2)
|
pyolivetree/client.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""The :class:`OliveTree` client: auth, request signing and endpoint groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as jsonlib
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from pyolivetree import notes as _notes
|
|
15
|
+
from pyolivetree._http import load_dotenv, parse_hmac_key, parse_response, sign_body
|
|
16
|
+
from pyolivetree.constants import DATA_SETS, PLATFORM_ID, TOKEN_ERRORS, WWW, DataSet
|
|
17
|
+
from pyolivetree.exceptions import OliveTreeAuthError, OliveTreeError
|
|
18
|
+
from pyolivetree.resources import (
|
|
19
|
+
AI,
|
|
20
|
+
Account,
|
|
21
|
+
Firebase,
|
|
22
|
+
Media,
|
|
23
|
+
Messages,
|
|
24
|
+
Products,
|
|
25
|
+
Reading,
|
|
26
|
+
Store,
|
|
27
|
+
Sync,
|
|
28
|
+
Telemetry,
|
|
29
|
+
Web,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OliveTree:
|
|
34
|
+
"""Client for the Olive Tree Bible app APIs.
|
|
35
|
+
|
|
36
|
+
Endpoints are grouped as attributes: :attr:`account`, :attr:`store`, :attr:`products`,
|
|
37
|
+
:attr:`sync`, :attr:`ai`, :attr:`reading`, :attr:`media`, :attr:`messages`,
|
|
38
|
+
:attr:`telemetry`, :attr:`web` and :attr:`firebase`. The client logs in on the
|
|
39
|
+
first call that needs it. :class:`AsyncOliveTree` offers the same API with ``await``.
|
|
40
|
+
|
|
41
|
+
Convenience methods cover the common jobs: :meth:`backup`, :meth:`notes`,
|
|
42
|
+
:meth:`highlights`, :meth:`clips`, :meth:`status` and :meth:`library`.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> with OliveTree() as ot: # doctest: +SKIP
|
|
46
|
+
... notes = list(ot.sync.pull("annotations"))
|
|
47
|
+
... owned = list(ot.store.all_products(ot.store.purchased()))
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
username: Olive Tree email. Defaults to ``OLIVE_TREE_USERNAME`` (env or ``.env``).
|
|
51
|
+
password: Password. Defaults to ``OLIVE_TREE_PASSWORD``.
|
|
52
|
+
device_id: Install id sent to the server. Defaults to ``OLIVE_TREE_INSTALL_ID`` or a new UUID.
|
|
53
|
+
install_id: Same as ``device_id`` (1.x name).
|
|
54
|
+
token: An existing ``authentication_token`` to reuse instead of logging in.
|
|
55
|
+
hmac_key: Signing key (hex, base64 or bytes) used for every signed endpoint instead of the
|
|
56
|
+
built-in per-service keys. Defaults to ``OLIVE_TREE_HMAC_KEY``.
|
|
57
|
+
transport: Custom :mod:`httpx` transport (e.g. :class:`httpx.MockTransport` in tests).
|
|
58
|
+
timeout: Request timeout in seconds.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
username: str | None = None,
|
|
64
|
+
password: str | None = None,
|
|
65
|
+
*,
|
|
66
|
+
device_id: str | None = None,
|
|
67
|
+
install_id: str | None = None,
|
|
68
|
+
token: str | None = None,
|
|
69
|
+
hmac_key: str | bytes | None = None,
|
|
70
|
+
transport: httpx.BaseTransport | None = None,
|
|
71
|
+
timeout: float = 300,
|
|
72
|
+
) -> None:
|
|
73
|
+
load_dotenv()
|
|
74
|
+
self.username = username or os.environ.get("OLIVE_TREE_USERNAME", "")
|
|
75
|
+
self.password = password or os.environ.get("OLIVE_TREE_PASSWORD", "")
|
|
76
|
+
self.device_id = (
|
|
77
|
+
device_id or install_id or os.environ.get("OLIVE_TREE_INSTALL_ID") or str(uuid.uuid4())
|
|
78
|
+
)
|
|
79
|
+
#: Current access token (``authentication_token``), or ``None`` before login.
|
|
80
|
+
self.token: str | None = token
|
|
81
|
+
#: Refresh token from the last login, used by :meth:`refresh`.
|
|
82
|
+
self.refresh_token: str | None = None
|
|
83
|
+
#: Signing key override, or ``None`` to use the built-in per-service keys.
|
|
84
|
+
self.hmac_key: bytes | None = parse_hmac_key(
|
|
85
|
+
hmac_key if hmac_key is not None else os.environ.get("OLIVE_TREE_HMAC_KEY")
|
|
86
|
+
)
|
|
87
|
+
self.reader_version = f"pyolivetree_OS_{platform.release()}"
|
|
88
|
+
#: Standard client fields sent with every store/account form post.
|
|
89
|
+
#: Without them some endpoints, e.g. ``store.purchased``, return empty results.
|
|
90
|
+
self.app_fields: dict[str, Any] = {
|
|
91
|
+
"platform": PLATFORM_ID,
|
|
92
|
+
"platudid": self.device_id,
|
|
93
|
+
"OS": "14",
|
|
94
|
+
"language": "en",
|
|
95
|
+
"install_id": self.device_id,
|
|
96
|
+
"supportsAccessToken": 1,
|
|
97
|
+
"ReaderVersion": "7.20",
|
|
98
|
+
"model": "pyolivetree",
|
|
99
|
+
"supportsOtPub": 1,
|
|
100
|
+
"supportsProductDRM": 1,
|
|
101
|
+
"supportsAudio": 1,
|
|
102
|
+
"supportsSubscription": 1,
|
|
103
|
+
"supportsBookSets": 1,
|
|
104
|
+
}
|
|
105
|
+
self.http = httpx.Client(
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
transport=transport,
|
|
108
|
+
follow_redirects=True,
|
|
109
|
+
headers={"Accept": "application/json, */*"},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self.account = Account(self) #: Login, tokens, signup, subscriptions.
|
|
113
|
+
self.store = Store(self) #: Catalog, search, purchases, wishlists.
|
|
114
|
+
self.products = Products(self) #: XML product info and download links.
|
|
115
|
+
self.sync = Sync(self) #: Notes, highlights, library and reading-plan data.
|
|
116
|
+
self.ai = AI(self) #: Smart Search AI.
|
|
117
|
+
self.reading = Reading(self) #: Reading plans, devotionals, verse of the day.
|
|
118
|
+
self.media = Media(self) #: Covers and media streams.
|
|
119
|
+
self.messages = Messages(self) #: In-app message center.
|
|
120
|
+
self.telemetry = Telemetry(self) #: Analytics, experiments, consent.
|
|
121
|
+
self.web = Web(self) #: Web pages and app config files.
|
|
122
|
+
self.firebase = Firebase(self) #: Firebase Realtime Database.
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> OliveTree:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, *_: Any) -> None:
|
|
128
|
+
self.close()
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
"""Close the underlying HTTP connection pool."""
|
|
132
|
+
self.http.close()
|
|
133
|
+
|
|
134
|
+
def login(self) -> dict:
|
|
135
|
+
"""Log in with the configured credentials. Same as :meth:`Account.login`.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
OliveTreeAuthError: If credentials are missing or rejected.
|
|
139
|
+
"""
|
|
140
|
+
if not (self.username and self.password):
|
|
141
|
+
raise OliveTreeAuthError("Set OLIVE_TREE_USERNAME and OLIVE_TREE_PASSWORD (or pass them in).")
|
|
142
|
+
return self.account.login()
|
|
143
|
+
|
|
144
|
+
def refresh(self) -> dict:
|
|
145
|
+
"""Renew the access token with the stored refresh token. Same as :meth:`Account.refresh_token`."""
|
|
146
|
+
return self.account.refresh_token()
|
|
147
|
+
|
|
148
|
+
def ensure_auth(self) -> None:
|
|
149
|
+
"""Log in now if there is no token yet."""
|
|
150
|
+
if not self.token:
|
|
151
|
+
self.login()
|
|
152
|
+
|
|
153
|
+
# --- convenience --------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def backup(
|
|
156
|
+
self, db: str = "olivetree.db", data_sets: Iterable[DataSet | str] = DATA_SETS
|
|
157
|
+
) -> dict[str, int]:
|
|
158
|
+
"""Mirror your sync data into a local SQLite file, fetching only changes since the last run.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
db: SQLite file to create or update.
|
|
162
|
+
data_sets: Data sets to back up (:class:`~pyolivetree.DataSet` members or names).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Rows fetched per data set, e.g. ``{"annotations": 12, "library": 0, ...}``.
|
|
166
|
+
"""
|
|
167
|
+
return _notes.backup(self, db, data_sets)
|
|
168
|
+
|
|
169
|
+
def notes(self, db: str = "olivetree.db", *, refresh: bool = True) -> list[dict]:
|
|
170
|
+
"""Your notes, in Bible order.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
db: Local backup file (created if missing).
|
|
174
|
+
refresh: Sync annotation changes into ``db`` first. ``False`` reads ``db`` offline.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
One dict per note: the row's fields (``title``, ``content``, ``created_date``, ...) plus
|
|
178
|
+
``type``, ``ref`` (e.g. ``"John 3:16-17"``) and ``color``.
|
|
179
|
+
"""
|
|
180
|
+
return self._annotations("note", db, refresh)
|
|
181
|
+
|
|
182
|
+
def highlights(self, db: str = "olivetree.db", *, refresh: bool = True) -> list[dict]:
|
|
183
|
+
"""Your highlights, in Bible order. ``color`` is the highlighter name. See :meth:`notes`."""
|
|
184
|
+
return self._annotations("highlight", db, refresh)
|
|
185
|
+
|
|
186
|
+
def clips(self, db: str = "olivetree.db", *, refresh: bool = True) -> list[dict]:
|
|
187
|
+
"""Verse text you saved, in Bible order. See :meth:`notes`."""
|
|
188
|
+
return self._annotations("clip", db, refresh)
|
|
189
|
+
|
|
190
|
+
def status(self) -> dict:
|
|
191
|
+
"""Sync server status and latest change numbers. Same as ``sync.status()``."""
|
|
192
|
+
return self.sync.status()
|
|
193
|
+
|
|
194
|
+
def library(self) -> list[dict]:
|
|
195
|
+
"""Every product you own, across all pages (``productId``, ``title``, ...)."""
|
|
196
|
+
return list(self.store.all_products(self.store.purchased()))
|
|
197
|
+
|
|
198
|
+
def _annotations(self, kind: str, db: str, refresh: bool) -> list[dict]:
|
|
199
|
+
if refresh:
|
|
200
|
+
_notes.backup(self, db, [DataSet.ANNOTATIONS])
|
|
201
|
+
return _notes.annotations(db, kind)
|
|
202
|
+
|
|
203
|
+
def _take_tokens(self, data: dict) -> None:
|
|
204
|
+
self.token = data.get("authentication_token") or self.token
|
|
205
|
+
self.refresh_token = data.get("refresh_token") or self.refresh_token
|
|
206
|
+
|
|
207
|
+
def request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
url: str,
|
|
211
|
+
*,
|
|
212
|
+
params: dict | None = None,
|
|
213
|
+
data: dict | None = None,
|
|
214
|
+
json: Any = None,
|
|
215
|
+
headers: dict | None = None,
|
|
216
|
+
auth: bool = True,
|
|
217
|
+
sign: str | None = None,
|
|
218
|
+
basic: bool = False,
|
|
219
|
+
) -> Any:
|
|
220
|
+
"""Send one request. Every endpoint method goes through here; use it for anything unwrapped.
|
|
221
|
+
|
|
222
|
+
``None`` values in ``params`` and ``data`` are dropped.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
method: HTTP method.
|
|
226
|
+
url: Full URL.
|
|
227
|
+
params: Query string values.
|
|
228
|
+
data: Form fields. Store/account URLs also get :attr:`app_fields`.
|
|
229
|
+
json: JSON body (sent compact, so the signature matches the bytes sent).
|
|
230
|
+
headers: Extra headers.
|
|
231
|
+
auth: Send ``OTAuthorization: Bearer <token>``, logging in first if needed.
|
|
232
|
+
sign: HMAC key profile for a ``signature`` header (``"telemetry"``, ``"ai"``, ...).
|
|
233
|
+
basic: Send HTTP Basic auth with :attr:`username` / :attr:`password`.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Parsed JSON, ``str`` for text/XML/HTML, or ``bytes``.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
OliveTreeError: On HTTP errors. After a token error the client logs in again and
|
|
240
|
+
retries once before raising.
|
|
241
|
+
"""
|
|
242
|
+
if url.startswith(f"{WWW}/store/br5/app/") and json is None:
|
|
243
|
+
data = {**self.app_fields, **(data or {})}
|
|
244
|
+
params, data, hdrs = _drop_none(params), _drop_none(data), dict(headers or {})
|
|
245
|
+
content = None
|
|
246
|
+
if json is not None:
|
|
247
|
+
content = jsonlib.dumps(json, separators=(",", ":")).encode()
|
|
248
|
+
hdrs["Content-Type"] = "application/json"
|
|
249
|
+
if sign:
|
|
250
|
+
hdrs["signature"] = sign_body(content or b"", sign, self.hmac_key)
|
|
251
|
+
basic_auth = (self.username, self.password) if basic else None
|
|
252
|
+
for attempt in (1, 2):
|
|
253
|
+
if auth:
|
|
254
|
+
self.ensure_auth()
|
|
255
|
+
hdrs["OTAuthorization"] = f"Bearer {self.token}"
|
|
256
|
+
r = self.http.request(
|
|
257
|
+
method, url, params=params, data=data, content=content, headers=hdrs, auth=basic_auth
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
return parse_response(r)
|
|
261
|
+
except OliveTreeError as e:
|
|
262
|
+
stale = e.code in TOKEN_ERRORS or e.status == 401
|
|
263
|
+
if not (auth and attempt == 1 and stale):
|
|
264
|
+
raise
|
|
265
|
+
self.token = None # log in again and retry once
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _drop_none(d: dict | None) -> dict | None:
|
|
269
|
+
return {k: v for k, v in d.items() if v is not None} if d else None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
#: Name used by pyolivetree 1.x.
|
|
273
|
+
OliveTreeClient = OliveTree
|