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.
- jmap/__about__.py +52 -0
- jmap/__init__.py +45 -0
- jmap/_shell.py +128 -0
- jmap/aio.py +377 -0
- jmap/api/__init__.py +3 -0
- jmap/api/entity.py +303 -0
- jmap/api/irregular.py +318 -0
- jmap/api/namespace.py +104 -0
- jmap/auth/__init__.py +117 -0
- jmap/auth/acquire.py +574 -0
- jmap/auth/challenge.py +156 -0
- jmap/auth/credentials.py +271 -0
- jmap/auth/flows.py +453 -0
- jmap/auth/metadata.py +222 -0
- jmap/auth/pkce.py +108 -0
- jmap/batch.py +360 -0
- jmap/blobs.py +105 -0
- jmap/capabilities/__init__.py +3 -0
- jmap/capabilities/blob.py +172 -0
- jmap/capabilities/calendars.py +323 -0
- jmap/capabilities/contacts.py +212 -0
- jmap/capabilities/core.py +74 -0
- jmap/capabilities/files.py +199 -0
- jmap/capabilities/mail.py +262 -0
- jmap/capabilities/mdn.py +61 -0
- jmap/capabilities/parsing.py +93 -0
- jmap/capabilities/principals.py +118 -0
- jmap/capabilities/push.py +93 -0
- jmap/capabilities/quota.py +40 -0
- jmap/capabilities/registry.py +351 -0
- jmap/capabilities/sieve.py +146 -0
- jmap/capabilities/spec.py +231 -0
- jmap/chunking.py +121 -0
- jmap/client.py +441 -0
- jmap/core/__init__.py +3 -0
- jmap/core/errors.py +240 -0
- jmap/core/ids.py +92 -0
- jmap/core/ijson.py +527 -0
- jmap/core/invocation.py +346 -0
- jmap/core/limits.py +287 -0
- jmap/core/narrow.py +54 -0
- jmap/core/patch.py +409 -0
- jmap/core/pointer.py +151 -0
- jmap/core/request.py +216 -0
- jmap/core/response.py +170 -0
- jmap/core/retry.py +175 -0
- jmap/core/session.py +377 -0
- jmap/core/uritemplate.py +192 -0
- jmap/defaults.py +66 -0
- jmap/discovery.py +170 -0
- jmap/models/__init__.py +3 -0
- jmap/models/base.py +258 -0
- jmap/models/blob.py +223 -0
- jmap/models/calendars.py +279 -0
- jmap/models/contacts.py +212 -0
- jmap/models/files.py +171 -0
- jmap/models/mail/__init__.py +3 -0
- jmap/models/mail/create.py +265 -0
- jmap/models/mail/headers.py +147 -0
- jmap/models/mail/objects.py +290 -0
- jmap/models/mdn.py +193 -0
- jmap/models/principals.py +148 -0
- jmap/models/push.py +201 -0
- jmap/models/quota.py +116 -0
- jmap/models/responses.py +170 -0
- jmap/models/sieve.py +81 -0
- jmap/push/__init__.py +84 -0
- jmap/push/eventsource.py +216 -0
- jmap/push/listener.py +398 -0
- jmap/push/sse.py +226 -0
- jmap/push/subscription.py +178 -0
- jmap/push/websocket.py +209 -0
- jmap/py.typed +0 -0
- jmap/sharing.py +203 -0
- jmap/sync/__init__.py +48 -0
- jmap/sync/changes.py +219 -0
- jmap/sync/query.py +304 -0
- jmap/sync/state.py +115 -0
- jmap/testing/__init__.py +7 -0
- jmap/testing/conformance.py +220 -0
- jmap/testing/fake.py +533 -0
- jmaplib-1.1.0.dist-info/METADATA +813 -0
- jmaplib-1.1.0.dist-info/RECORD +85 -0
- jmaplib-1.1.0.dist-info/WHEEL +4 -0
- 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