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.
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ from pyolivetree.cli import main
2
+
3
+ main()
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