backd 0.0.1__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.
backd/__init__.py ADDED
@@ -0,0 +1,284 @@
1
+ """Python client for backd: auth, sessions and data for services, scripts and notebooks.
2
+
3
+ ``Client`` is the sync client and ``AsyncClient`` the async one; both have the same calls.
4
+ """
5
+
6
+ from ._async.client import AsyncClient
7
+ from ._async.data import AsyncCollection, AsyncDatabase
8
+ from ._async.functions import AsyncJob
9
+ from ._async.realtime import AsyncLiveView
10
+ from ._live import LiveState, LiveStatus, LiveSubscriptionState, ReferenceSpec, SubscribeOptions, SubscriptionSpec
11
+ from ._retry import Retry
12
+ from ._sync.client import Client
13
+ from ._sync.data import Collection, Database
14
+ from ._sync.functions import Job
15
+ from ._sync.realtime import LiveView
16
+ from .cache import CacheOptions, CacheStats
17
+ from .config_storage import ConfigStorage, FileConfigStorage, MemoryConfigStorage
18
+ from .errors import (
19
+ AggregateNotEnabledError,
20
+ AggregateTooExpensiveError,
21
+ AuthenticationError,
22
+ BackdError,
23
+ ConflictError,
24
+ ConsentRequiredError,
25
+ ErrorDetail,
26
+ ExpandTooLargeError,
27
+ FieldEncryptedError,
28
+ ForbiddenError,
29
+ IdTakenError,
30
+ IndexBuildingError,
31
+ InvalidAggregateError,
32
+ InvalidExpandError,
33
+ InvalidIdError,
34
+ InvalidResumeTokenError,
35
+ JobTimeoutError,
36
+ LastOrgManagerError,
37
+ NetworkError,
38
+ NotAllowedError,
39
+ NotFoundError,
40
+ NotOrgMemberError,
41
+ NotTeamMemberError,
42
+ OrgRequiresMFAError,
43
+ OrgRoleRequiresMFAError,
44
+ PasswordExpiredError,
45
+ RealtimeNotEnabledError,
46
+ RetryableError,
47
+ SearchNotEnabledError,
48
+ SearchTooExpensiveError,
49
+ SecondStepRequiredError,
50
+ SessionEndedError,
51
+ SsoRequiredError,
52
+ SsoSessionError,
53
+ TeamError,
54
+ TooManySubscriptionsError,
55
+ ValidationError,
56
+ VerificationRequiredError,
57
+ VersionMismatchError,
58
+ )
59
+ from .files import public_url, version_status
60
+ from .models import (
61
+ AggregateGroup,
62
+ AggregateResult,
63
+ BatchOperation,
64
+ ConsentChallenge,
65
+ ConsentInfo,
66
+ ConsentState,
67
+ ConsentText,
68
+ DataExport,
69
+ Device,
70
+ DeviceInput,
71
+ Doc,
72
+ EnrollmentChallenge,
73
+ FileDetails,
74
+ FileField,
75
+ FileLink,
76
+ Identity,
77
+ JobData,
78
+ JobResult,
79
+ JobStep,
80
+ Me,
81
+ Meta,
82
+ MfaChallenge,
83
+ Org,
84
+ OrgConnection,
85
+ OrgInvitation,
86
+ OrgMember,
87
+ OrgTeam,
88
+ OrgTeamMember,
89
+ OrgTeamRoleGrant,
90
+ OrgTemplate,
91
+ OrgTemplateText,
92
+ Page,
93
+ PreparedUpload,
94
+ Profile,
95
+ ProviderSession,
96
+ RoleOrigin,
97
+ Session,
98
+ SessionInfo,
99
+ SignInResult,
100
+ User,
101
+ VersionState,
102
+ )
103
+ from .oauth import MemoryOAuthStorage, OAuthReturn, OAuthStorage
104
+ from .query import (
105
+ Where,
106
+ and_,
107
+ between,
108
+ bucket_key,
109
+ contains,
110
+ ends_with,
111
+ eq,
112
+ expand,
113
+ fill_buckets,
114
+ gt,
115
+ gte,
116
+ icontains,
117
+ iends_with,
118
+ ilike,
119
+ in_,
120
+ is_null,
121
+ istarts_with,
122
+ like,
123
+ lt,
124
+ lte,
125
+ ne,
126
+ nin,
127
+ not_null,
128
+ or_,
129
+ search,
130
+ starts_with,
131
+ )
132
+ from .size import SizeFamily, format_bytes, parse_size
133
+ from .storage import FileStorage, MemoryStorage, SlotStorage, TokenStorage
134
+ from .upload import Progress, Upload, UploadSource, part_size_for
135
+
136
+ __version__ = "0.0.1"
137
+
138
+ __all__ = [
139
+ "AggregateGroup",
140
+ "AggregateNotEnabledError",
141
+ "AggregateResult",
142
+ "AggregateTooExpensiveError",
143
+ "AsyncClient",
144
+ "AsyncCollection",
145
+ "AsyncDatabase",
146
+ "AsyncJob",
147
+ "AsyncLiveView",
148
+ "AuthenticationError",
149
+ "BackdError",
150
+ "BatchOperation",
151
+ "CacheOptions",
152
+ "CacheStats",
153
+ "Client",
154
+ "Collection",
155
+ "ConfigStorage",
156
+ "ConflictError",
157
+ "ConsentChallenge",
158
+ "ConsentInfo",
159
+ "ConsentRequiredError",
160
+ "ConsentState",
161
+ "ConsentText",
162
+ "DataExport",
163
+ "Database",
164
+ "Device",
165
+ "DeviceInput",
166
+ "Doc",
167
+ "EnrollmentChallenge",
168
+ "ErrorDetail",
169
+ "ExpandTooLargeError",
170
+ "FieldEncryptedError",
171
+ "FileConfigStorage",
172
+ "FileDetails",
173
+ "FileField",
174
+ "FileLink",
175
+ "FileStorage",
176
+ "ForbiddenError",
177
+ "IdTakenError",
178
+ "Identity",
179
+ "IndexBuildingError",
180
+ "InvalidAggregateError",
181
+ "InvalidExpandError",
182
+ "InvalidIdError",
183
+ "InvalidResumeTokenError",
184
+ "Job",
185
+ "JobData",
186
+ "JobResult",
187
+ "JobStep",
188
+ "JobTimeoutError",
189
+ "LastOrgManagerError",
190
+ "LiveState",
191
+ "LiveStatus",
192
+ "LiveSubscriptionState",
193
+ "LiveView",
194
+ "Me",
195
+ "MemoryConfigStorage",
196
+ "MemoryOAuthStorage",
197
+ "MemoryStorage",
198
+ "Meta",
199
+ "MfaChallenge",
200
+ "NetworkError",
201
+ "NotAllowedError",
202
+ "NotFoundError",
203
+ "NotOrgMemberError",
204
+ "NotTeamMemberError",
205
+ "OAuthReturn",
206
+ "OAuthStorage",
207
+ "Org",
208
+ "OrgConnection",
209
+ "OrgInvitation",
210
+ "OrgMember",
211
+ "OrgRequiresMFAError",
212
+ "OrgRoleRequiresMFAError",
213
+ "OrgTeam",
214
+ "OrgTeamMember",
215
+ "OrgTeamRoleGrant",
216
+ "OrgTemplate",
217
+ "OrgTemplateText",
218
+ "Page",
219
+ "PasswordExpiredError",
220
+ "PreparedUpload",
221
+ "Profile",
222
+ "Progress",
223
+ "ProviderSession",
224
+ "RealtimeNotEnabledError",
225
+ "ReferenceSpec",
226
+ "Retry",
227
+ "RetryableError",
228
+ "RoleOrigin",
229
+ "SearchNotEnabledError",
230
+ "SearchTooExpensiveError",
231
+ "SecondStepRequiredError",
232
+ "Session",
233
+ "SessionEndedError",
234
+ "SessionInfo",
235
+ "SignInResult",
236
+ "SizeFamily",
237
+ "SlotStorage",
238
+ "SsoRequiredError",
239
+ "SsoSessionError",
240
+ "SubscribeOptions",
241
+ "SubscriptionSpec",
242
+ "TeamError",
243
+ "TokenStorage",
244
+ "TooManySubscriptionsError",
245
+ "Upload",
246
+ "UploadSource",
247
+ "User",
248
+ "ValidationError",
249
+ "VerificationRequiredError",
250
+ "VersionMismatchError",
251
+ "VersionState",
252
+ "Where",
253
+ "__version__",
254
+ "and_",
255
+ "between",
256
+ "bucket_key",
257
+ "contains",
258
+ "ends_with",
259
+ "eq",
260
+ "expand",
261
+ "fill_buckets",
262
+ "format_bytes",
263
+ "gt",
264
+ "gte",
265
+ "icontains",
266
+ "iends_with",
267
+ "ilike",
268
+ "in_",
269
+ "is_null",
270
+ "istarts_with",
271
+ "like",
272
+ "lt",
273
+ "lte",
274
+ "ne",
275
+ "nin",
276
+ "not_null",
277
+ "or_",
278
+ "parse_size",
279
+ "part_size_for",
280
+ "public_url",
281
+ "search",
282
+ "starts_with",
283
+ "version_status",
284
+ ]
backd/_aio.py ADDED
@@ -0,0 +1,29 @@
1
+ """The few places where the async client needs a different function from the sync one.
2
+ ``scripts/unasync.py`` swaps this module for ``_sio`` when it writes ``_sync``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from collections.abc import Awaitable, Callable, Coroutine
8
+ from typing import Any, TypeVar, cast
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ async def sleep(seconds: float) -> None:
14
+ await asyncio.sleep(seconds)
15
+
16
+
17
+ _tasks: set[asyncio.Task[None]] = set()
18
+
19
+
20
+ def spawn(fn: Callable[..., Awaitable[None]], *args: Any) -> None:
21
+ """Runs ``fn(*args)`` in the background: a task here, a thread in the sync client."""
22
+ task: asyncio.Task[None] = asyncio.get_running_loop().create_task(cast("Coroutine[Any, Any, None]", fn(*args)))
23
+ _tasks.add(task) # a task nothing refers to may be collected before it ends
24
+ task.add_done_callback(_tasks.discard)
25
+
26
+
27
+ async def run_blocking(fn: Callable[..., T], *args: Any) -> T:
28
+ """Runs a blocking function (hashing a big file) without stopping the event loop; the sync client just calls it."""
29
+ return await asyncio.to_thread(fn, *args)
@@ -0,0 +1 @@
1
+ """The async client. These files are the source of ``_sync``; run ``python scripts/unasync.py``."""
@@ -0,0 +1,153 @@
1
+ """The signed-in user's own account data: ``client.account``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from collections.abc import AsyncIterator, Callable
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, BinaryIO, cast
10
+
11
+ import httpx
12
+
13
+ from .._aio import sleep
14
+ from ..errors import BackdError
15
+ from ..models import DataExport
16
+
17
+ if TYPE_CHECKING:
18
+ from .client import AsyncClient
19
+
20
+ __all__ = ["AsyncAccount", "AsyncExportDownload"]
21
+
22
+
23
+ class AsyncExportDownload:
24
+ """A ready export, to save. ``url`` is a link signed for a few minutes, when the realm delivers by link
25
+ (``expires_at`` says when it stops working); a realm whose storage clients cannot reach sends the zip through the
26
+ API and ``url`` is ``None``. Either way :meth:`read` and :meth:`save` give the zip."""
27
+
28
+ def __init__(
29
+ self,
30
+ client: AsyncClient,
31
+ response: httpx.Response | None,
32
+ url: str | None = None,
33
+ expires_at: str | None = None,
34
+ ) -> None:
35
+ self._client = client
36
+ self._response = response
37
+ self.url = url
38
+ self.expires_at = expires_at
39
+
40
+ async def _stream(self) -> AsyncIterator[bytes]:
41
+ if self._response is None:
42
+ assert self.url is not None
43
+ response = await self._client.open_external("GET", self.url)
44
+ if not response.is_success:
45
+ await response.aclose()
46
+ raise BackdError(
47
+ f"the export link answered {response.status_code}",
48
+ status=response.status_code,
49
+ code="download_failed",
50
+ )
51
+ else:
52
+ response = self._response
53
+ try:
54
+ async for chunk in response.aiter_bytes():
55
+ yield chunk
56
+ finally:
57
+ await response.aclose()
58
+
59
+ async def read(self) -> bytes:
60
+ """The zip's bytes."""
61
+ return b"".join([chunk async for chunk in self._stream()])
62
+
63
+ async def save(self, to: str | os.PathLike[str] | BinaryIO) -> int:
64
+ """Writes the zip to a path or an open binary file as it arrives; returns the bytes written."""
65
+ n = 0
66
+ if hasattr(to, "write"):
67
+ async for chunk in self._stream():
68
+ cast(BinaryIO, to).write(chunk)
69
+ n += len(chunk)
70
+ return n
71
+ with Path(to).open("wb") as f:
72
+ async for chunk in self._stream():
73
+ f.write(chunk)
74
+ n += len(chunk)
75
+ return n
76
+
77
+
78
+ class AsyncAccount:
79
+ """The signed-in user's own account data: ``client.account``. A person can download a copy of everything the
80
+ realm holds about them (documents, files and account data, with a ``README.html``): ask for it, follow it, and
81
+ save it when it is ready.
82
+
83
+ client.auth.reauth({"method": "password", "password": password}) # a fresh proof, as for any sensitive action
84
+ job = client.account.request_export() # {"id": …, "status": "queued", …}
85
+ ready = client.account.wait_for_export(job["id"]) # polls until it is ready
86
+ client.account.download_export(ready["id"]).save("export.zip")
87
+
88
+ The realm must set ``compliance.export: true`` (:class:`NotFoundError` otherwise). A user may ask once every 24
89
+ hours: a second request is a :class:`RetryableError` (``429``) whose ``retry_after`` says, in seconds, how long to
90
+ wait. When the package is ready the user is also emailed a link to the app's download page.
91
+ """
92
+
93
+ def __init__(self, client: AsyncClient) -> None:
94
+ self._client = client
95
+
96
+ async def request_export(self) -> DataExport:
97
+ """Asks for an export of the user's personal data. Needs a fresh proof: call ``client.auth.reauth()`` first (a
98
+ ``reauth_required`` error says so). It is built in the background."""
99
+ res = await self._client.request("POST", ["_auth", "me", "export"], reauth=True)
100
+ return cast(DataExport, res.data)
101
+
102
+ async def exports(self) -> list[DataExport]:
103
+ """The exports the user asked for, newest first."""
104
+ res = await self._client.request("GET", ["_auth", "me", "exports"], cache=False)
105
+ return cast("list[DataExport]", cast(dict[str, Any], res.data)["items"])
106
+
107
+ async def export(self, export_id: str) -> DataExport:
108
+ """One export, for a page that waits for it: ``step`` says what it is doing while it runs."""
109
+ res = await self._client.request("GET", ["_auth", "me", "exports", export_id], cache=False)
110
+ return cast(DataExport, res.data)
111
+
112
+ async def wait_for_export(
113
+ self,
114
+ export_id: str,
115
+ *,
116
+ poll_interval: float = 2.0,
117
+ timeout: float = 1800.0,
118
+ on_progress: Callable[[DataExport], None] | None = None,
119
+ ) -> DataExport:
120
+ """Polls an export until it is ``ready``. A ``failed`` or ``expired`` one raises a :class:`BackdError` (code
121
+ ``export_failed`` or ``export_expired``: the user may ask again), and so does waiting longer than ``timeout``
122
+ (``export_timeout``). Polls every 2 s for up to 30 minutes by default."""
123
+ end = time.monotonic() + timeout
124
+ while True:
125
+ e = await self.export(export_id)
126
+ if on_progress is not None:
127
+ on_progress(e)
128
+ if e["status"] == "ready":
129
+ return e
130
+ if e["status"] in ("failed", "expired"):
131
+ hint = ": ask for a new one" if e["status"] == "failed" else ""
132
+ raise BackdError(f"the export {e['status']}{hint}", code=f"export_{e['status']}")
133
+ if time.monotonic() + poll_interval > end:
134
+ raise BackdError(f"the export is still {e['status']} after {timeout:.0f}s", code="export_timeout")
135
+ await sleep(poll_interval)
136
+
137
+ async def download_export(self, export_id: str) -> AsyncExportDownload:
138
+ """Gives access to a ready export. :class:`ConflictError` until it is ready, and ``410 gone`` once it has
139
+ expired. A realm that delivers by link answers with ``url`` (open it, or :meth:`~AsyncExportDownload.read`
140
+ reads it); one whose storage clients cannot reach sends the zip through the API."""
141
+ res = await self._client.request(
142
+ "GET",
143
+ ["_auth", "me", "exports", export_id, "download"],
144
+ raw=True,
145
+ headers={"Accept": "application/json"},
146
+ )
147
+ assert res.response is not None
148
+ if not res.response.headers.get("Content-Type", "").startswith("application/json"):
149
+ return AsyncExportDownload(self._client, res.response)
150
+ await res.response.aread()
151
+ await res.response.aclose()
152
+ link = res.response.json()
153
+ return AsyncExportDownload(self._client, None, link.get("url"), link.get("expires_at"))