jmaplib 1.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.
Files changed (85) hide show
  1. jmap/__about__.py +52 -0
  2. jmap/__init__.py +45 -0
  3. jmap/_shell.py +128 -0
  4. jmap/aio.py +377 -0
  5. jmap/api/__init__.py +3 -0
  6. jmap/api/entity.py +303 -0
  7. jmap/api/irregular.py +318 -0
  8. jmap/api/namespace.py +104 -0
  9. jmap/auth/__init__.py +117 -0
  10. jmap/auth/acquire.py +574 -0
  11. jmap/auth/challenge.py +156 -0
  12. jmap/auth/credentials.py +271 -0
  13. jmap/auth/flows.py +453 -0
  14. jmap/auth/metadata.py +222 -0
  15. jmap/auth/pkce.py +108 -0
  16. jmap/batch.py +360 -0
  17. jmap/blobs.py +105 -0
  18. jmap/capabilities/__init__.py +3 -0
  19. jmap/capabilities/blob.py +172 -0
  20. jmap/capabilities/calendars.py +323 -0
  21. jmap/capabilities/contacts.py +212 -0
  22. jmap/capabilities/core.py +74 -0
  23. jmap/capabilities/files.py +199 -0
  24. jmap/capabilities/mail.py +262 -0
  25. jmap/capabilities/mdn.py +61 -0
  26. jmap/capabilities/parsing.py +93 -0
  27. jmap/capabilities/principals.py +118 -0
  28. jmap/capabilities/push.py +93 -0
  29. jmap/capabilities/quota.py +40 -0
  30. jmap/capabilities/registry.py +351 -0
  31. jmap/capabilities/sieve.py +146 -0
  32. jmap/capabilities/spec.py +231 -0
  33. jmap/chunking.py +121 -0
  34. jmap/client.py +441 -0
  35. jmap/core/__init__.py +3 -0
  36. jmap/core/errors.py +240 -0
  37. jmap/core/ids.py +92 -0
  38. jmap/core/ijson.py +527 -0
  39. jmap/core/invocation.py +346 -0
  40. jmap/core/limits.py +287 -0
  41. jmap/core/narrow.py +54 -0
  42. jmap/core/patch.py +409 -0
  43. jmap/core/pointer.py +151 -0
  44. jmap/core/request.py +216 -0
  45. jmap/core/response.py +170 -0
  46. jmap/core/retry.py +175 -0
  47. jmap/core/session.py +377 -0
  48. jmap/core/uritemplate.py +192 -0
  49. jmap/defaults.py +66 -0
  50. jmap/discovery.py +170 -0
  51. jmap/models/__init__.py +3 -0
  52. jmap/models/base.py +258 -0
  53. jmap/models/blob.py +223 -0
  54. jmap/models/calendars.py +279 -0
  55. jmap/models/contacts.py +212 -0
  56. jmap/models/files.py +171 -0
  57. jmap/models/mail/__init__.py +3 -0
  58. jmap/models/mail/create.py +265 -0
  59. jmap/models/mail/headers.py +147 -0
  60. jmap/models/mail/objects.py +290 -0
  61. jmap/models/mdn.py +193 -0
  62. jmap/models/principals.py +148 -0
  63. jmap/models/push.py +201 -0
  64. jmap/models/quota.py +116 -0
  65. jmap/models/responses.py +170 -0
  66. jmap/models/sieve.py +81 -0
  67. jmap/push/__init__.py +84 -0
  68. jmap/push/eventsource.py +216 -0
  69. jmap/push/listener.py +398 -0
  70. jmap/push/sse.py +226 -0
  71. jmap/push/subscription.py +178 -0
  72. jmap/push/websocket.py +209 -0
  73. jmap/py.typed +0 -0
  74. jmap/sharing.py +203 -0
  75. jmap/sync/__init__.py +48 -0
  76. jmap/sync/changes.py +219 -0
  77. jmap/sync/query.py +304 -0
  78. jmap/sync/state.py +115 -0
  79. jmap/testing/__init__.py +7 -0
  80. jmap/testing/conformance.py +220 -0
  81. jmap/testing/fake.py +533 -0
  82. jmaplib-1.1.0.dist-info/METADATA +813 -0
  83. jmaplib-1.1.0.dist-info/RECORD +85 -0
  84. jmaplib-1.1.0.dist-info/WHEEL +4 -0
  85. jmaplib-1.1.0.dist-info/licenses/LICENSE +21 -0
jmap/__about__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Version and spec-revision provenance for jmaplib.
2
+
3
+ ``SPEC_REVISIONS`` is published so downstream users can detect when a capability
4
+ this library implements is tracking a draft that has since moved on. Capabilities
5
+ marked experimental are excluded from the SemVer promise precisely because these
6
+ revisions can change under us.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Final
12
+
13
+ # Unannotated on purpose: hatchling's default version regex only matches a bare
14
+ # `__version__ = "..."` assignment.
15
+ __version__ = "1.1.0"
16
+
17
+ #: Maps a capability URN to the exact spec revision this library implements.
18
+ SPEC_REVISIONS: Final[dict[str, str]] = {
19
+ "urn:ietf:params:jmap:core": "RFC 8620",
20
+ "urn:ietf:params:jmap:mail": "RFC 8621",
21
+ "urn:ietf:params:jmap:submission": "RFC 8621",
22
+ "urn:ietf:params:jmap:vacationresponse": "RFC 8621",
23
+ "urn:ietf:params:jmap:websocket": "RFC 8887",
24
+ "urn:ietf:params:jmap:mdn": "RFC 9007",
25
+ "urn:ietf:params:jmap:smimeverify": "RFC 9219",
26
+ "urn:ietf:params:jmap:blob": "RFC 9404",
27
+ "urn:ietf:params:jmap:quota": "RFC 9425",
28
+ "urn:ietf:params:jmap:contacts": "RFC 9610",
29
+ # No IETF document defines a contacts `:parse`; the URN and the shape behind
30
+ # it are Stalwart's, mirroring the calendars companion.
31
+ "urn:ietf:params:jmap:contacts:parse": "Stalwart vendor extension (v0.16)",
32
+ # The pre-RFC contacts model, gated by vendor URNs rather than by the IETF
33
+ # one. A server may advertise both at once; Cyrus 3.10 does.
34
+ "https://www.fastmail.com/dev/contacts": "pre-RFC Contact/ContactGroup",
35
+ "https://cyrusimap.org/ns/jmap/contacts": "pre-RFC Contact/ContactGroup",
36
+ "urn:ietf:params:jmap:principals": "RFC 9670",
37
+ "urn:ietf:params:jmap:principals:owner": "RFC 9670",
38
+ "urn:ietf:params:jmap:sieve": "RFC 9661",
39
+ "urn:ietf:params:jmap:webpush-vapid": "RFC 9749",
40
+ # Experimental — tracking drafts, excluded from the SemVer promise.
41
+ #
42
+ # The calendars draft is in the RFC Editor queue but *blocked* on
43
+ # draft-ietf-calext-jscalendarbis, and its own normative reference (bis-17) is
44
+ # already behind the current bis-18. Its wire names can still change; the
45
+ # event body is JSCalendar 2.0, not RFC 8984.
46
+ "urn:ietf:params:jmap:calendars": "draft-ietf-jmap-calendars-27",
47
+ "urn:ietf:params:jmap:calendars:parse": "draft-ietf-jmap-calendars-27",
48
+ # Availability lives in the calendars draft, *not* in RFC 9670 — that
49
+ # document defines no custom methods at all.
50
+ "urn:ietf:params:jmap:principals:availability": "draft-ietf-jmap-calendars-27",
51
+ "urn:ietf:params:jmap:filenode": "draft-ietf-jmap-filenode-14",
52
+ }
jmap/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """jmaplib - a complete, capability-driven JMAP client for Python.
2
+
3
+ Install as ``jmaplib``; import as ``jmap``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from jmap.__about__ import SPEC_REVISIONS, __version__
9
+ from jmap.core.errors import (
10
+ AuthenticationError,
11
+ BatchTooLargeError,
12
+ CapabilityFieldError,
13
+ CapabilityNotSupportedError,
14
+ JMAPError,
15
+ MethodError,
16
+ RequestError,
17
+ ServerPartialFailError,
18
+ SetError,
19
+ SetFailedError,
20
+ TransportError,
21
+ )
22
+ from jmap.core.ids import CreationRef, Id, InvalidIdError, is_valid_id, parse_id
23
+ from jmap.core.invocation import NestedResultRefError
24
+
25
+ __all__ = [
26
+ "SPEC_REVISIONS",
27
+ "AuthenticationError",
28
+ "BatchTooLargeError",
29
+ "CapabilityFieldError",
30
+ "CapabilityNotSupportedError",
31
+ "CreationRef",
32
+ "Id",
33
+ "InvalidIdError",
34
+ "JMAPError",
35
+ "MethodError",
36
+ "NestedResultRefError",
37
+ "RequestError",
38
+ "ServerPartialFailError",
39
+ "SetError",
40
+ "SetFailedError",
41
+ "TransportError",
42
+ "__version__",
43
+ "is_valid_id",
44
+ "parse_id",
45
+ ]
jmap/_shell.py ADDED
@@ -0,0 +1,128 @@
1
+ """Logic shared by the sync and async clients.
2
+
3
+ The two shells differ only in where they put ``await``. Everything that can be
4
+ decided without doing I/O lives here so there is one implementation of it, and a
5
+ bug fixed in one flavour cannot survive in the other.
6
+
7
+ Nothing in this module performs I/O. It takes what a response *was* - a status,
8
+ some headers, a parsed body - and says what should happen next.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Final, cast
14
+
15
+ from jmap.core.errors import RequestError, TransportError
16
+ from jmap.core.ijson import loads
17
+ from jmap.core.retry import Failure, Safety, classify, parse_retry_after
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Mapping
21
+
22
+ from jmap.core.session import Session
23
+
24
+ #: RFC 8620 §3.1. Servers are entitled to reject anything else, and Stalwart does.
25
+ JMAP_CONTENT_TYPE: Final = "application/json"
26
+
27
+ PROBLEM_CONTENT_TYPE: Final = "application/problem+json"
28
+
29
+ #: A conservative default. The library never sends `Accept-Language` unless asked,
30
+ #: but RFC 8620 §3.8 says the server SHOULD honour it for localised error text.
31
+ DEFAULT_HEADERS: Final[Mapping[str, str]] = {
32
+ "Content-Type": JMAP_CONTENT_TYPE,
33
+ "Accept": JMAP_CONTENT_TYPE,
34
+ }
35
+
36
+
37
+ def request_headers(*, accept_language: str | None = None) -> dict[str, str]:
38
+ headers = dict(DEFAULT_HEADERS)
39
+ if accept_language:
40
+ headers["Accept-Language"] = accept_language
41
+ return headers
42
+
43
+
44
+ #: Why a blob operation could not work out which account it belongs to. Its own
45
+ #: constant because the reason differs from every other account resolution in the
46
+ #: library: `primaryAccounts` cannot answer for blobs at all, so pointing at it
47
+ #: would send the reader looking for a session field that is correctly absent.
48
+ NO_BLOB_ACCOUNT: Final = (
49
+ "and this session implies no single account: blobs have no primaryAccounts "
50
+ "entry to look up (RFC 8620 §2 keys that map by capability, and blobs belong "
51
+ "to none), the session holds more than one account, and its primaryAccounts "
52
+ "entries do not all name the same one - so pass account_id, or set a default "
53
+ "on the client"
54
+ )
55
+
56
+
57
+ def problem_of(status: int, headers: Mapping[str, str], body: bytes) -> RequestError | None:
58
+ """Build a :class:`RequestError` if the response is an RFC 7807 problem.
59
+
60
+ Matched on status rather than content type alone: Stalwart labels its 401 as
61
+ ``application/problem+json`` but plenty of intermediaries return a bare 502
62
+ with an HTML body, and both need to become a typed error rather than a JSON
63
+ decode failure.
64
+ """
65
+ if 200 <= status < 300:
66
+ return None
67
+ content_type = headers.get("content-type", headers.get("Content-Type", ""))
68
+ if PROBLEM_CONTENT_TYPE in content_type:
69
+ try:
70
+ parsed = as_json_object(loads(body), "the API")
71
+ except (ValueError, TransportError):
72
+ parsed = None
73
+ if parsed is not None:
74
+ return RequestError.from_problem(parsed, status=status)
75
+ return RequestError(
76
+ "about:blank",
77
+ status=status,
78
+ title=f"HTTP {status}",
79
+ detail=body[:512].decode("utf-8", "replace") or None,
80
+ )
81
+
82
+
83
+ def as_json_object(value: Any, source: str) -> dict[str, Any]:
84
+ """Narrow a parsed JSON value to an object, or say which endpoint misbehaved.
85
+
86
+ The cast is what stops an `Any` from a JSON decode leaking into typed code:
87
+ `isinstance` alone narrows to an unparameterised dict, which a strict type
88
+ checker reports as partially unknown for the rest of its life.
89
+ """
90
+ if not isinstance(value, dict):
91
+ raise TransportError(f"{source} returned a JSON value that is not an object")
92
+ return cast("dict[str, Any]", value)
93
+
94
+
95
+ def failure_of(status: int, problem: RequestError | None) -> Safety:
96
+ """Classify an HTTP response for retry purposes."""
97
+ return classify(
98
+ Failure.STATUS,
99
+ status=status,
100
+ problem_type=problem.type if problem is not None else None,
101
+ )
102
+
103
+
104
+ def retry_delay(
105
+ headers: Mapping[str, str], *, policy_delay: float, now: float | None = None
106
+ ) -> float:
107
+ """Prefer the server's ``Retry-After`` over our own backoff curve."""
108
+ server_hint = parse_retry_after(headers, now=now)
109
+ return server_hint if server_hint is not None else policy_delay
110
+
111
+
112
+ def session_is_stale(session: Session, response_session_state: str) -> bool:
113
+ """Whether a response says our cached session is out of date (RFC 8620 §3.4).
114
+
115
+ An empty state on either side means "unknown", which is treated as fresh: a
116
+ server that omits ``sessionState`` should not put the client into a refetch
117
+ loop.
118
+ """
119
+ if not response_session_state or not session.state:
120
+ return False
121
+ return response_session_state != session.state
122
+
123
+
124
+ def merged_created_ids(existing: Mapping[str, Any], incoming: Mapping[str, Any]) -> dict[str, Any]:
125
+ """Carry creation ids forward between the requests of a split batch."""
126
+ merged = dict(existing)
127
+ merged.update(incoming)
128
+ return merged
jmap/aio.py ADDED
@@ -0,0 +1,377 @@
1
+ """The asynchronous JMAP client.
2
+
3
+ Deliberately a mirror of :mod:`jmap.client`: the two differ only in where the
4
+ ``await`` goes and which httpx class they hold. Every decision either makes -
5
+ what ``using`` needs, how to split a batch, whether a failure may be retried - is
6
+ taken by the same I/O-free code, so the twins cannot disagree about protocol
7
+ behaviour, only about concurrency.
8
+
9
+ That similarity is load-bearing and worth preserving. If these files start to
10
+ drift, the fix is to move the difference down into the kernel, not to special-case
11
+ it here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import TYPE_CHECKING, Any, Self
18
+
19
+ import anyio
20
+ import httpx
21
+
22
+ from jmap._shell import (
23
+ NO_BLOB_ACCOUNT,
24
+ as_json_object,
25
+ failure_of,
26
+ problem_of,
27
+ request_headers,
28
+ retry_delay,
29
+ session_is_stale,
30
+ )
31
+ from jmap.api.namespace import Namespaces
32
+ from jmap.batch import Batch, NoAccountError, all_mutations_guarded, is_mutating
33
+ from jmap.blobs import (
34
+ UploadResult,
35
+ check_upload_size,
36
+ download_url,
37
+ parse_upload,
38
+ upload_headers,
39
+ upload_url,
40
+ )
41
+ from jmap.capabilities.core import CORE_URN
42
+ from jmap.core.errors import AuthenticationError, TransportError
43
+ from jmap.core.ijson import dumps, loads
44
+ from jmap.core.response import Response
45
+ from jmap.core.retry import Failure, RetryPolicy, Safety, classify, should_retry
46
+ from jmap.core.session import Session
47
+ from jmap.defaults import default_registry
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Mapping
51
+ from types import TracebackType
52
+
53
+ from jmap.capabilities.registry import ActiveCapabilities, Registry
54
+ from jmap.core.ids import Id
55
+ from jmap.core.invocation import Handle
56
+ from jmap.core.request import Request
57
+
58
+
59
+ class AsyncBatchContext:
60
+ """A batch that executes when its ``async with`` block exits."""
61
+
62
+ __slots__ = ("_batch", "_client", "_extra_using", "_namespaces")
63
+
64
+ def __init__(self, client: AsyncJMAPClient, batch: Batch, extra_using: frozenset[str]) -> None:
65
+ self._client = client
66
+ self._batch = batch
67
+ self._extra_using = extra_using
68
+ self._namespaces = Namespaces(batch, client.capabilities)
69
+
70
+ def __getattr__(self, name: str) -> Any:
71
+ """Capability namespaces: ``batch.mail.email.get(...)``."""
72
+ return getattr(self._namespaces, name)
73
+
74
+ def add(
75
+ self,
76
+ name: str,
77
+ arguments: Mapping[str, Any] | None = None,
78
+ *,
79
+ account_id: Id | None = None,
80
+ ) -> Handle[Any]:
81
+ return self._batch.add(name, arguments, account_id=account_id)
82
+
83
+ async def __aenter__(self) -> Self:
84
+ return self
85
+
86
+ async def __aexit__(
87
+ self,
88
+ exc_type: type[BaseException] | None,
89
+ exc: BaseException | None,
90
+ tb: TracebackType | None,
91
+ ) -> None:
92
+ # A block that raised has an unknown intent past the failure point.
93
+ if exc_type is None:
94
+ await self._client.execute(self._batch, extra_using=self._extra_using)
95
+
96
+
97
+ class AsyncJMAPClient:
98
+ """An asynchronous JMAP client bound to one session."""
99
+
100
+ session: Session
101
+ #: Where the session was fetched from, so it can be refetched.
102
+ session_url: str
103
+ capabilities: ActiveCapabilities
104
+ registry: Registry
105
+ retry_policy: RetryPolicy
106
+ default_account: Id | None
107
+ #: Set when a response reports a different ``sessionState``. The session is
108
+ #: not refetched automatically - that would turn one stale read into a
109
+ #: surprise round trip in the middle of someone's batch.
110
+ session_stale: bool
111
+
112
+ def __init__(
113
+ self,
114
+ session: Session,
115
+ capabilities: ActiveCapabilities,
116
+ http: httpx.AsyncClient,
117
+ *,
118
+ registry: Registry,
119
+ retry_policy: RetryPolicy | None = None,
120
+ default_account: Id | None = None,
121
+ owns_http: bool = False,
122
+ session_url: str = "",
123
+ ) -> None:
124
+ self.session = session
125
+ self.session_url = session_url or session.api_url
126
+ self.capabilities = capabilities
127
+ self.registry = registry
128
+ self.retry_policy = retry_policy or RetryPolicy()
129
+ self.default_account = default_account
130
+ self._http = http
131
+ self._owns_http = owns_http
132
+ self.session_stale = False
133
+
134
+ # -- construction ------------------------------------------------------- #
135
+ @classmethod
136
+ async def connect(
137
+ cls,
138
+ url: str,
139
+ *,
140
+ auth: httpx.Auth,
141
+ registry: Registry | None = None,
142
+ account_id: Id | None = None,
143
+ experimental: bool = False,
144
+ retry_policy: RetryPolicy | None = None,
145
+ http: httpx.AsyncClient | None = None,
146
+ timeout: float = 30.0,
147
+ ) -> Self:
148
+ """Fetch the session from ``url`` and resolve what this server supports."""
149
+ owns_http = http is None
150
+ client = http or httpx.AsyncClient(follow_redirects=True, timeout=timeout)
151
+ try:
152
+ session = await _fetch_session(client, url, auth=auth)
153
+ except BaseException:
154
+ if owns_http:
155
+ await client.aclose()
156
+ raise
157
+
158
+ client.auth = auth
159
+ registry = registry or default_registry()
160
+ account = account_id or _primary_account(session)
161
+ return cls(
162
+ session,
163
+ registry.resolve(session, account, experimental=experimental),
164
+ client,
165
+ registry=registry,
166
+ retry_policy=retry_policy,
167
+ default_account=account,
168
+ owns_http=owns_http,
169
+ session_url=url,
170
+ )
171
+
172
+ async def refresh_session(self) -> None:
173
+ """Refetch the session and re-resolve capabilities."""
174
+ self.session = await _fetch_session(self._http, self.session_url, auth=None)
175
+ self.capabilities = self.registry.resolve(self.session, self.default_account)
176
+ self.session_stale = False
177
+
178
+ @property
179
+ def http(self) -> httpx.AsyncClient:
180
+ """The underlying HTTP client.
181
+
182
+ Exposed so the push shells can hold a streaming connection open on the
183
+ same authenticated client rather than opening a second one - the event
184
+ source needs exactly the credentials this client already carries.
185
+ """
186
+ return self._http
187
+
188
+ # -- calling ------------------------------------------------------------ #
189
+ def batch(self, *, extra_using: frozenset[str] = frozenset()) -> AsyncBatchContext:
190
+ """Open a batch. Calls queued inside it travel in one request."""
191
+ return AsyncBatchContext(
192
+ self,
193
+ Batch(self.capabilities, default_account=self.default_account),
194
+ extra_using,
195
+ )
196
+
197
+ async def call(
198
+ self,
199
+ name: str,
200
+ arguments: Mapping[str, Any] | None = None,
201
+ *,
202
+ account_id: Id | None = None,
203
+ ) -> Any:
204
+ """Make a single call and return its parsed result."""
205
+ batch = Batch(self.capabilities, default_account=self.default_account)
206
+ handle = batch.add(name, arguments, account_id=account_id)
207
+ await self.execute(batch)
208
+ return handle.result
209
+
210
+ async def echo(self, **arguments: Any) -> Any:
211
+ """``Core/echo`` - the cheapest proof that auth and routing both work."""
212
+ return await self.call("Core/echo", arguments)
213
+
214
+ async def execute(self, batch: Batch, *, extra_using: frozenset[str] = frozenset()) -> None:
215
+ """Send ``batch``, possibly as several requests, and resolve its handles."""
216
+ for request in batch.plan(extra_using=extra_using):
217
+ response = await self._post(request, batch)
218
+ batch.absorb(response)
219
+ if session_is_stale(self.session, response.session_state):
220
+ self.session_stale = True
221
+
222
+ # -- blobs -------------------------------------------------------------- #
223
+ async def upload(
224
+ self,
225
+ content: bytes,
226
+ *,
227
+ content_type: str | None = None,
228
+ account_id: Id | None = None,
229
+ ) -> UploadResult:
230
+ """Upload a blob and return its id (RFC 8620 §6.1).
231
+
232
+ Blobs move over plain HTTP rather than as method calls, so this is not
233
+ batchable and takes no ``using``.
234
+ """
235
+ account = account_id or self.default_account or self.session.implied_account()
236
+ if account is None:
237
+ raise NoAccountError("upload", CORE_URN, reason=NO_BLOB_ACCOUNT)
238
+ check_upload_size(len(content), self.capabilities.limits)
239
+ response = await self._http.post(
240
+ upload_url(self.session, account),
241
+ content=content,
242
+ headers=upload_headers(content_type),
243
+ )
244
+ problem = problem_of(response.status_code, response.headers, response.content)
245
+ if problem is not None:
246
+ raise problem
247
+ return parse_upload(as_json_object(loads(response.content), "the upload endpoint"))
248
+
249
+ async def download(
250
+ self,
251
+ blob_id: str,
252
+ *,
253
+ name: str = "download",
254
+ content_type: str = "application/octet-stream",
255
+ account_id: Id | None = None,
256
+ ) -> bytes:
257
+ """Fetch a blob's bytes (RFC 8620 §6.2).
258
+
259
+ ``name`` and ``content_type`` only shape the response headers; the blob
260
+ is addressed by ``blob_id`` alone.
261
+ """
262
+ account = account_id or self.default_account or self.session.implied_account()
263
+ if account is None:
264
+ raise NoAccountError("download", CORE_URN, reason=NO_BLOB_ACCOUNT)
265
+ response = await self._http.get(
266
+ download_url(self.session, account, blob_id, name=name, content_type=content_type)
267
+ )
268
+ problem = problem_of(response.status_code, response.headers, response.content)
269
+ if problem is not None:
270
+ raise problem
271
+ return response.content
272
+
273
+ # -- transport ---------------------------------------------------------- #
274
+ async def _post(self, request: Request, batch: Batch) -> Response:
275
+ mutating = is_mutating(batch, self.capabilities)
276
+ guarded = all_mutations_guarded(batch, self.capabilities)
277
+ body = dumps(request.to_wire()).encode()
278
+ attempt = 0
279
+
280
+ safety: Safety
281
+ delay: float | None
282
+ error: BaseException
283
+
284
+ while True:
285
+ attempt += 1
286
+ try:
287
+ response = await self._http.post(
288
+ self.session.api_url, content=body, headers=request_headers()
289
+ )
290
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
291
+ # Only these prove the request was never sent - see the sync
292
+ # client for why the catch-all below must stay MAYBE_APPLIED.
293
+ safety, delay, error = classify(Failure.CONNECT), None, exc
294
+ except httpx.TimeoutException as exc:
295
+ safety, delay, error = classify(Failure.TIMEOUT), None, exc
296
+ except httpx.HTTPError as exc:
297
+ safety, delay, error = classify(Failure.INTERRUPTED), None, exc
298
+ else:
299
+ problem = problem_of(response.status_code, response.headers, response.content)
300
+ if problem is None:
301
+ return Response.from_wire(as_json_object(loads(response.content), "the API"))
302
+ if response.status_code == 401:
303
+ raise AuthenticationError(
304
+ "the server rejected these credentials",
305
+ challenges=tuple(response.headers.get_list("www-authenticate")),
306
+ )
307
+ safety = failure_of(response.status_code, problem)
308
+ delay = retry_delay(
309
+ response.headers,
310
+ policy_delay=self.retry_policy.backoff(attempt),
311
+ now=time.time(),
312
+ )
313
+ error = problem
314
+
315
+ if not should_retry(
316
+ safety,
317
+ policy=self.retry_policy,
318
+ attempt=attempt,
319
+ mutating=mutating,
320
+ all_mutations_guarded=guarded,
321
+ ):
322
+ raise _as_error(error)
323
+ # anyio rather than asyncio.sleep so the client works under trio too.
324
+ await anyio.sleep(delay if delay is not None else self.retry_policy.backoff(attempt))
325
+
326
+ # -- lifecycle ---------------------------------------------------------- #
327
+ async def aclose(self) -> None:
328
+ if self._owns_http:
329
+ await self._http.aclose()
330
+
331
+ async def __aenter__(self) -> Self:
332
+ return self
333
+
334
+ async def __aexit__(
335
+ self,
336
+ exc_type: type[BaseException] | None,
337
+ exc: BaseException | None,
338
+ tb: TracebackType | None,
339
+ ) -> None:
340
+ await self.aclose()
341
+
342
+ def __repr__(self) -> str:
343
+ return (
344
+ f"AsyncJMAPClient(username={self.session.username!r}, account={self.default_account!r})"
345
+ )
346
+
347
+
348
+ def _as_error(error: BaseException) -> BaseException:
349
+ if isinstance(error, httpx.HTTPError):
350
+ return TransportError(str(error) or type(error).__name__)
351
+ return error
352
+
353
+
354
+ async def _fetch_session(http: httpx.AsyncClient, url: str, *, auth: httpx.Auth | None) -> Session:
355
+ kwargs: dict[str, Any] = {"headers": {"Accept": "application/json"}}
356
+ if auth is not None:
357
+ kwargs["auth"] = auth
358
+ try:
359
+ response = await http.get(url, **kwargs)
360
+ except httpx.HTTPError as exc:
361
+ raise _as_error(exc) from exc
362
+ if response.status_code == 401:
363
+ raise AuthenticationError(
364
+ "the server rejected these credentials",
365
+ challenges=tuple(response.headers.get_list("www-authenticate")),
366
+ )
367
+ problem = problem_of(response.status_code, response.headers, response.content)
368
+ if problem is not None:
369
+ raise problem
370
+ parsed = as_json_object(loads(response.content), "the session endpoint")
371
+ return Session.from_wire(parsed, base_url=str(response.url))
372
+
373
+
374
+ def _primary_account(session: Session) -> Id | None:
375
+ from jmap.capabilities.core import CORE_URN
376
+
377
+ return session.primary_account_for(CORE_URN)
jmap/api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Typed method builders composed from capability specs."""
2
+
3
+ from __future__ import annotations