graphplug 0.2.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.
- graphplug/__init__.py +378 -0
- graphplug/_auth.py +390 -0
- graphplug/_errors.py +176 -0
- graphplug/_http.py +198 -0
- graphplug/_log.py +104 -0
- graphplug/_operations.py +310 -0
- graphplug/_request.py +135 -0
- graphplug/_resources/__init__.py +10 -0
- graphplug/_resources/base.py +70 -0
- graphplug/_resources/calendar.py +194 -0
- graphplug/_resources/files.py +149 -0
- graphplug/_resources/mail.py +196 -0
- graphplug/_resources/teams.py +146 -0
- graphplug/_resources/users.py +121 -0
- graphplug/_scopes.py +56 -0
- graphplug/py.typed +0 -0
- graphplug-0.2.0.dist-info/METADATA +234 -0
- graphplug-0.2.0.dist-info/RECORD +21 -0
- graphplug-0.2.0.dist-info/WHEEL +5 -0
- graphplug-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphplug-0.2.0.dist-info/top_level.txt +1 -0
graphplug/_http.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The transport: Microsoft's middleware pipeline, the bearer token, and the concurrency limit.
|
|
2
|
+
|
|
3
|
+
Everything that touches the wire is here. The rest of the package sees dictionaries.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from msgraph_core import GraphClientFactory
|
|
14
|
+
|
|
15
|
+
from . import _log
|
|
16
|
+
from ._errors import GraphError, as_graph_error, from_response
|
|
17
|
+
from ._request import allowlisted
|
|
18
|
+
|
|
19
|
+
__all__ = ["Transport", "GRAPH_HOST", "DEFAULT_MAX_CONCURRENCY"]
|
|
20
|
+
|
|
21
|
+
#: The only host the bearer token may be attached to. Absolute URLs off this host -- a
|
|
22
|
+
#: pre-authenticated download URL, say -- still work; they just travel unauthenticated.
|
|
23
|
+
GRAPH_HOST = "graph.microsoft.com"
|
|
24
|
+
|
|
25
|
+
#: Deliberately modest. Graph throttles per application and per tenant, and mailbox operations are
|
|
26
|
+
#: limited to a handful of concurrent requests per mailbox, so the ceiling is the service's rather
|
|
27
|
+
#: than Python's. Going wider earns 429s, not throughput.
|
|
28
|
+
DEFAULT_MAX_CONCURRENCY = 12
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Transport:
|
|
32
|
+
"""One authenticated HTTP session over Microsoft's middleware pipeline."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
credential: Any,
|
|
37
|
+
scopes: Sequence[str],
|
|
38
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
39
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
40
|
+
owns_credential: bool = True,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._credential = credential
|
|
43
|
+
# A credential the caller handed in is the caller's to close; it may be shared.
|
|
44
|
+
self._owns_credential = owns_credential
|
|
45
|
+
self._scopes = tuple(scopes)
|
|
46
|
+
self._gate = asyncio.Semaphore(max_concurrency)
|
|
47
|
+
|
|
48
|
+
# A caller-supplied client is the test seam: pass one built on httpx.MockTransport and the
|
|
49
|
+
# middleware still wraps it, so tests exercise the real pipeline.
|
|
50
|
+
self._client = GraphClientFactory.create_with_default_middleware(client=client)
|
|
51
|
+
self._closed = False
|
|
52
|
+
|
|
53
|
+
# ── the wire ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async def send(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
url: str,
|
|
59
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
60
|
+
json_body: Any = None,
|
|
61
|
+
content: Optional[bytes] = None,
|
|
62
|
+
stream: bool = False,
|
|
63
|
+
operation: str = "request",
|
|
64
|
+
) -> httpx.Response:
|
|
65
|
+
"""Issue one request through the pipeline, with the token attached if the host allows it.
|
|
66
|
+
|
|
67
|
+
Anything that fails before a response exists -- the credential, DNS, TLS, a timeout --
|
|
68
|
+
is raised as a ``GraphError`` here, so every caller gets the one error shape.
|
|
69
|
+
"""
|
|
70
|
+
if self._closed:
|
|
71
|
+
raise GraphError(0, "invalidHandle", "this client has already been closed")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return await self._send(method, url, headers, json_body, content, stream)
|
|
75
|
+
except GraphError:
|
|
76
|
+
raise
|
|
77
|
+
except Exception as exception:
|
|
78
|
+
raise as_graph_error(exception, operation) from exception
|
|
79
|
+
|
|
80
|
+
async def _send(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
url: str,
|
|
84
|
+
headers: Optional[Mapping[str, str]],
|
|
85
|
+
json_body: Any,
|
|
86
|
+
content: Optional[bytes],
|
|
87
|
+
stream: bool,
|
|
88
|
+
) -> httpx.Response:
|
|
89
|
+
request = self._client.build_request(
|
|
90
|
+
method, url, headers=dict(headers or {}), json=json_body, content=content
|
|
91
|
+
)
|
|
92
|
+
await self._authorize(request)
|
|
93
|
+
|
|
94
|
+
# msgraph-core's transport runs its middleware only when the request carries `options`:
|
|
95
|
+
#
|
|
96
|
+
# if self.pipeline and hasattr(request, 'options'):
|
|
97
|
+
#
|
|
98
|
+
# That attribute is normally set by Kiota's RequestAdapter, which this package does not
|
|
99
|
+
# use. Without it the whole pipeline -- retry, Retry-After, redirect, telemetry -- is
|
|
100
|
+
# silently skipped and requests go straight to the socket. Setting it here is the one
|
|
101
|
+
# place that undocumented contract is relied upon, and test_transport.py fails loudly if
|
|
102
|
+
# it ever stops working.
|
|
103
|
+
request.options = {} # type: ignore[attr-defined]
|
|
104
|
+
|
|
105
|
+
started = time.monotonic()
|
|
106
|
+
async with self._gate:
|
|
107
|
+
if stream:
|
|
108
|
+
return await self._client.send(request, stream=True)
|
|
109
|
+
response = await self._client.send(request)
|
|
110
|
+
|
|
111
|
+
_log.request(
|
|
112
|
+
method,
|
|
113
|
+
url,
|
|
114
|
+
response.status_code,
|
|
115
|
+
int((time.monotonic() - started) * 1000),
|
|
116
|
+
request_id=response.headers.get("request-id"),
|
|
117
|
+
error_code=None if response.is_success else _peek_error_code(response),
|
|
118
|
+
)
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
async def _authorize(self, request: httpx.Request) -> None:
|
|
122
|
+
"""Attach the bearer token, but only for Graph itself.
|
|
123
|
+
|
|
124
|
+
Kiota's Python middleware has no authorization handler -- unlike .NET, where
|
|
125
|
+
AuthorizationHandler both attached the token and enforced allowed hosts. Both are done
|
|
126
|
+
here, and the host check is what stops a pre-authenticated download URL on some other
|
|
127
|
+
domain being handed a Graph token.
|
|
128
|
+
"""
|
|
129
|
+
if request.url.host != GRAPH_HOST:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
token = await self._credential.get_token(*self._scopes)
|
|
133
|
+
request.headers["Authorization"] = f"Bearer {token.token}"
|
|
134
|
+
|
|
135
|
+
# ── helpers the rest of the package uses ─────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async def json(
|
|
138
|
+
self,
|
|
139
|
+
method: str,
|
|
140
|
+
url: str,
|
|
141
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
142
|
+
body: Any = None,
|
|
143
|
+
operation: str = "request",
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
"""Send, then return the envelope: status, allow-listed headers, body, next link."""
|
|
146
|
+
response = await self.send(method, url, headers=headers, json_body=body, operation=operation)
|
|
147
|
+
return self.envelope(response)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def envelope(response: httpx.Response) -> Dict[str, Any]:
|
|
151
|
+
"""Turn a response into the one shape, raising on failure."""
|
|
152
|
+
headers = allowlisted(response.headers)
|
|
153
|
+
body = _decode(response)
|
|
154
|
+
|
|
155
|
+
if not response.is_success:
|
|
156
|
+
raise from_response(response.status_code, headers, body, response.reason_phrase)
|
|
157
|
+
|
|
158
|
+
envelope: Dict[str, Any] = {
|
|
159
|
+
"status": response.status_code,
|
|
160
|
+
"headers": headers,
|
|
161
|
+
"body": body,
|
|
162
|
+
}
|
|
163
|
+
if isinstance(body, dict) and body.get("@odata.nextLink"):
|
|
164
|
+
# Promoted so callers never need to know the OData annotation name. Left in the body
|
|
165
|
+
# too, so the Graph response stays verbatim.
|
|
166
|
+
envelope["nextLink"] = body["@odata.nextLink"]
|
|
167
|
+
return envelope
|
|
168
|
+
|
|
169
|
+
async def aclose(self) -> None:
|
|
170
|
+
if not self._closed:
|
|
171
|
+
self._closed = True
|
|
172
|
+
await self._client.aclose()
|
|
173
|
+
closer = getattr(self._credential, "close", None) if self._owns_credential else None
|
|
174
|
+
if closer is not None:
|
|
175
|
+
result = closer()
|
|
176
|
+
if asyncio.iscoroutine(result):
|
|
177
|
+
await result
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def closed(self) -> bool:
|
|
181
|
+
return self._closed
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _decode(response: httpx.Response) -> Any:
|
|
185
|
+
if response.status_code == 204 or not response.content:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
return response.json()
|
|
189
|
+
except ValueError:
|
|
190
|
+
# Not every Graph failure body is JSON -- a gateway can return HTML.
|
|
191
|
+
return response.text
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _peek_error_code(response: httpx.Response) -> Optional[str]:
|
|
195
|
+
body = _decode(response)
|
|
196
|
+
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
|
197
|
+
return body["error"].get("code")
|
|
198
|
+
return str(response.status_code)
|
graphplug/_log.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Opt-in structured logging.
|
|
2
|
+
|
|
3
|
+
One JSON object per line on stderr, off unless ``GRAPHPLUG_LOG_LEVEL`` asks for it. Output goes to
|
|
4
|
+
stderr because a caller may be piping stdout.
|
|
5
|
+
|
|
6
|
+
Redaction is structural rather than a rule to remember: every function here takes the exact fields
|
|
7
|
+
it may emit, so there is no free-form call through which a header, a token or a request body could
|
|
8
|
+
reach a line. URLs are logged without their query string, because an OData ``$filter`` routinely
|
|
9
|
+
carries email addresses.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Dict, Iterator, Optional, TextIO
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
__all__ = ["LEVEL_VARIABLE", "capture", "is_enabled", "parse_level", "request", "failure"]
|
|
22
|
+
|
|
23
|
+
LEVEL_VARIABLE = "GRAPHPLUG_LOG_LEVEL"
|
|
24
|
+
|
|
25
|
+
OFF, ERROR, INFO = 0, 1, 2
|
|
26
|
+
_NAMES = {ERROR: "error", INFO: "info"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_level(value: Optional[str]) -> int:
|
|
30
|
+
"""Anything unrecognised means off: a typo must not silently enable logging."""
|
|
31
|
+
return {"error": ERROR, "info": INFO}.get((value or "").strip().lower(), OFF)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_configured = parse_level(os.environ.get(LEVEL_VARIABLE))
|
|
35
|
+
|
|
36
|
+
#: Test-only redirection. Production never sets it, so the production path holds no mutable state.
|
|
37
|
+
_override: Optional[tuple] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _level() -> int:
|
|
41
|
+
return _override[0] if _override else _configured
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _writer() -> TextIO:
|
|
45
|
+
return _override[1] if _override else sys.stderr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_enabled(level: int) -> bool:
|
|
49
|
+
return level <= _level()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextlib.contextmanager
|
|
53
|
+
def capture(level: int, writer: TextIO) -> Iterator[None]:
|
|
54
|
+
"""Capture output at the given level for the duration of the block. Tests only."""
|
|
55
|
+
global _override
|
|
56
|
+
previous, _override = _override, (level, writer)
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
_override = previous
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write(level: int, fields: Dict[str, Any]) -> None:
|
|
64
|
+
try:
|
|
65
|
+
line = json.dumps({"level": _NAMES[level], **fields}, separators=(",", ":"))
|
|
66
|
+
print(line, file=_writer(), flush=True)
|
|
67
|
+
except Exception:
|
|
68
|
+
# A diagnostic must never be the thing that takes the process down.
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _path_only(url: str) -> str:
|
|
73
|
+
"""Scheme, host and path. The query string is never logged."""
|
|
74
|
+
parts = urlsplit(url)
|
|
75
|
+
return f"{parts.scheme}://{parts.netloc}{parts.path}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def request(
|
|
79
|
+
method: str,
|
|
80
|
+
url: str,
|
|
81
|
+
status: int,
|
|
82
|
+
elapsed_ms: int,
|
|
83
|
+
request_id: Optional[str] = None,
|
|
84
|
+
error_code: Optional[str] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
level = ERROR if error_code else INFO
|
|
87
|
+
if not is_enabled(level):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
_write(level, {
|
|
91
|
+
"event": "request",
|
|
92
|
+
"method": method,
|
|
93
|
+
"url": _path_only(url),
|
|
94
|
+
"status": status,
|
|
95
|
+
"ms": elapsed_ms,
|
|
96
|
+
"requestId": request_id,
|
|
97
|
+
"errorCode": error_code,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def failure(operation: str, code: str, message: str) -> None:
|
|
102
|
+
"""A failure that produced no response. The message is what the caller already receives."""
|
|
103
|
+
if is_enabled(ERROR):
|
|
104
|
+
_write(ERROR, {"event": "failure", "operation": operation, "code": code, "message": message})
|
graphplug/_operations.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Paging, batching and file transfer.
|
|
2
|
+
|
|
3
|
+
Ported from the C# core's `BatchOperation`, `DownloadOperation` and the two upload strategies.
|
|
4
|
+
None of msgraph-core's own helpers can be used for these: `BatchRequestBuilder`, `PageIterator` and
|
|
5
|
+
`LargeFileUploadTask` each require a Kiota `RequestAdapter` and `Parsable` models, which this
|
|
6
|
+
package deliberately does not build.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Sequence, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from ._errors import GraphError
|
|
18
|
+
from ._http import Transport
|
|
19
|
+
from ._request import allowlisted, build_url
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"paged", "batch", "download", "upload",
|
|
23
|
+
"MAX_BATCH_SIZE", "CHUNKED_THRESHOLD_BYTES", "CHUNK_SIZE", "CHUNK_ALIGNMENT",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
#: Graph's hard limit on requests per batch.
|
|
27
|
+
MAX_BATCH_SIZE = 20
|
|
28
|
+
|
|
29
|
+
#: Below this a single PUT is fine; at or above it Graph wants an upload session.
|
|
30
|
+
CHUNKED_THRESHOLD_BYTES = 4 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
#: Graph requires an upload session's chunk size to be a multiple of 320 KiB.
|
|
33
|
+
CHUNK_ALIGNMENT = 320 * 1024
|
|
34
|
+
CHUNK_SIZE = 10 * 1024 * 1024 # 32 whole alignment units
|
|
35
|
+
|
|
36
|
+
_COPY_BUFFER = 1 << 16
|
|
37
|
+
_MAX_CHUNK_ATTEMPTS = 3
|
|
38
|
+
|
|
39
|
+
BatchRequest = Union[Tuple[str, str], Mapping[str, Any]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── paging ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def paged(
|
|
46
|
+
transport: Transport,
|
|
47
|
+
url: str,
|
|
48
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
49
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
50
|
+
"""Walk every page, yielding items.
|
|
51
|
+
|
|
52
|
+
Nothing buffers the whole collection, and abandoning the generator leaks nothing: the next
|
|
53
|
+
link is a complete, self-describing cursor held only by the caller's loop.
|
|
54
|
+
|
|
55
|
+
``headers`` is re-sent on every page, because a header that qualifies the query -- Graph's
|
|
56
|
+
``ConsistencyLevel: eventual`` for advanced queries -- has to hold for the whole walk.
|
|
57
|
+
"""
|
|
58
|
+
while url:
|
|
59
|
+
envelope = await transport.json("GET", url, headers=headers, operation="paged")
|
|
60
|
+
body = envelope.get("body") or {}
|
|
61
|
+
|
|
62
|
+
for item in body.get("value", []):
|
|
63
|
+
yield item
|
|
64
|
+
|
|
65
|
+
url = envelope.get("nextLink") or ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── batching ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prepare(requests: Sequence[BatchRequest]) -> List[Dict[str, Any]]:
|
|
72
|
+
"""Normalise to Graph's sub-request shape, assigning ids so ordering has a key."""
|
|
73
|
+
prepared: List[Dict[str, Any]] = []
|
|
74
|
+
for index, request in enumerate(requests):
|
|
75
|
+
if isinstance(request, tuple):
|
|
76
|
+
method, url = request
|
|
77
|
+
item: Dict[str, Any] = {"method": method, "url": url}
|
|
78
|
+
else:
|
|
79
|
+
item = dict(request)
|
|
80
|
+
item.setdefault("id", str(index))
|
|
81
|
+
prepared.append(item)
|
|
82
|
+
return prepared
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def batch(
|
|
86
|
+
transport: Transport,
|
|
87
|
+
requests: Sequence[BatchRequest],
|
|
88
|
+
version: Optional[str] = None,
|
|
89
|
+
) -> List[Dict[str, Any]]:
|
|
90
|
+
"""Send many requests as one call.
|
|
91
|
+
|
|
92
|
+
Two pieces of behaviour belong here rather than to the caller. Graph rejects batches larger
|
|
93
|
+
than 20, so a bigger set is split; and Graph does not guarantee response order within a batch,
|
|
94
|
+
so submission order is restored and results align positionally with what was sent.
|
|
95
|
+
|
|
96
|
+
Per-request failures are not raised -- each result keeps its own ``status``. One failing
|
|
97
|
+
sub-request must not discard nineteen successful ones.
|
|
98
|
+
"""
|
|
99
|
+
prepared = _prepare(requests)
|
|
100
|
+
if not prepared:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
chunks = [prepared[i:i + MAX_BATCH_SIZE] for i in range(0, len(prepared), MAX_BATCH_SIZE)]
|
|
104
|
+
url = build_url("/$batch", version)
|
|
105
|
+
|
|
106
|
+
# Chunks go concurrently; the transport's semaphore is what keeps that within Graph's limits.
|
|
107
|
+
responses = await asyncio.gather(*(
|
|
108
|
+
transport.json("POST", url, body={"requests": chunk}, operation="batch")
|
|
109
|
+
for chunk in chunks
|
|
110
|
+
), return_exceptions=True)
|
|
111
|
+
|
|
112
|
+
# A chunk that failed outright must not discard the chunks that ran -- with send_many, those
|
|
113
|
+
# mails have gone, and a caller who only saw an exception would send them twice. Its requests
|
|
114
|
+
# are reported in place like any failed sub-request. Only when nothing ran is it raised.
|
|
115
|
+
failures = [r for r in responses if isinstance(r, BaseException)]
|
|
116
|
+
if len(failures) == len(responses):
|
|
117
|
+
raise failures[0]
|
|
118
|
+
|
|
119
|
+
merged: List[Dict[str, Any]] = []
|
|
120
|
+
for chunk, envelope in zip(chunks, responses):
|
|
121
|
+
if isinstance(envelope, GraphError):
|
|
122
|
+
merged.extend(_failed(chunk, envelope))
|
|
123
|
+
elif isinstance(envelope, BaseException):
|
|
124
|
+
raise envelope
|
|
125
|
+
else:
|
|
126
|
+
merged.extend(_ordered(chunk, (envelope.get("body") or {}).get("responses", [])))
|
|
127
|
+
return merged
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _failed(sent: Sequence[Mapping[str, Any]], error: GraphError) -> List[Dict[str, Any]]:
|
|
131
|
+
"""One result per request in a chunk that never reached Graph, shaped like a sub-response."""
|
|
132
|
+
body = {"error": {"code": error.code, "message": error.message}}
|
|
133
|
+
return [{"id": str(item["id"]), "status": error.status, "body": body} for item in sent]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ordered(sent: Sequence[Mapping[str, Any]], returned: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
|
|
137
|
+
"""Restore submission order. Graph returns sub-responses in whatever order they completed."""
|
|
138
|
+
by_id = {str(item.get("id")): dict(item) for item in returned if item.get("id") is not None}
|
|
139
|
+
return [by_id[str(item["id"])] for item in sent if str(item["id"]) in by_id]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── download ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def download(transport: Transport, url: str, dest_path: str) -> Dict[str, Any]:
|
|
146
|
+
"""Stream a response straight to disk.
|
|
147
|
+
|
|
148
|
+
The bytes never enter a JSON envelope and never fully enter memory, so size is bounded by disk
|
|
149
|
+
rather than RAM. The destination directory must already exist; this does not create it.
|
|
150
|
+
"""
|
|
151
|
+
destination = Path(dest_path)
|
|
152
|
+
if not destination.parent.exists():
|
|
153
|
+
raise GraphError(
|
|
154
|
+
0, "invalidRequest", f"the destination directory '{destination.parent}' does not exist"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
response = await transport.send("GET", url, stream=True, operation="download")
|
|
158
|
+
try:
|
|
159
|
+
if not response.is_success:
|
|
160
|
+
await response.aread()
|
|
161
|
+
return Transport.envelope(response) # raises with the Graph error
|
|
162
|
+
|
|
163
|
+
# Written to a temporary sibling and renamed on success, so a failed or cancelled
|
|
164
|
+
# download cannot leave a truncated file at the destination path.
|
|
165
|
+
partial = destination.with_name(f"{destination.name}.{uuid.uuid4().hex}.partial")
|
|
166
|
+
written = 0
|
|
167
|
+
try:
|
|
168
|
+
with partial.open("wb") as handle:
|
|
169
|
+
async for block in response.aiter_bytes(_COPY_BUFFER):
|
|
170
|
+
handle.write(block)
|
|
171
|
+
written += len(block)
|
|
172
|
+
os.replace(partial, destination)
|
|
173
|
+
except BaseException:
|
|
174
|
+
partial.unlink(missing_ok=True)
|
|
175
|
+
raise
|
|
176
|
+
finally:
|
|
177
|
+
await response.aclose()
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"status": response.status_code,
|
|
181
|
+
"headers": allowlisted(response.headers),
|
|
182
|
+
"bytesWritten": written,
|
|
183
|
+
"destPath": str(destination),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── upload ───────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
_CONTENT_SUFFIX = ":/content"
|
|
190
|
+
_SESSION_SUFFIX = ":/createUploadSession"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def to_session_path(path: str) -> str:
|
|
194
|
+
"""Turn the content path the caller wrote into the session path Graph expects.
|
|
195
|
+
|
|
196
|
+
Both strategies take the same input, so the caller never learns which one ran.
|
|
197
|
+
"""
|
|
198
|
+
if path.endswith(_CONTENT_SUFFIX):
|
|
199
|
+
return path[: -len(_CONTENT_SUFFIX)] + _SESSION_SUFFIX
|
|
200
|
+
if path.endswith("/content"):
|
|
201
|
+
return path[: -len("/content")] + "/createUploadSession"
|
|
202
|
+
return path
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def upload(
|
|
206
|
+
transport: Transport,
|
|
207
|
+
path: str,
|
|
208
|
+
source_path: str,
|
|
209
|
+
version: Optional[str] = None,
|
|
210
|
+
) -> Dict[str, Any]:
|
|
211
|
+
"""Send a local file, choosing the strategy by size."""
|
|
212
|
+
source = Path(source_path)
|
|
213
|
+
if not source.is_file():
|
|
214
|
+
raise GraphError(0, "invalidRequest", f"'{source_path}' does not exist")
|
|
215
|
+
|
|
216
|
+
size = source.stat().st_size
|
|
217
|
+
if size < CHUNKED_THRESHOLD_BYTES:
|
|
218
|
+
envelope = await _upload_simple(transport, path, source, version)
|
|
219
|
+
else:
|
|
220
|
+
envelope = await _upload_chunked(transport, path, source, size, version)
|
|
221
|
+
|
|
222
|
+
envelope["bytesSent"] = size
|
|
223
|
+
return envelope
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _upload_simple(
|
|
227
|
+
transport: Transport, path: str, source: Path, version: Optional[str]
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
"""A single PUT of the file. Used below 4 MiB."""
|
|
230
|
+
response = await transport.send(
|
|
231
|
+
"PUT",
|
|
232
|
+
build_url(path, version),
|
|
233
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
234
|
+
content=source.read_bytes(),
|
|
235
|
+
operation="upload",
|
|
236
|
+
)
|
|
237
|
+
return Transport.envelope(response)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _upload_chunked(
|
|
241
|
+
transport: Transport, path: str, source: Path, size: int, version: Optional[str]
|
|
242
|
+
) -> Dict[str, Any]:
|
|
243
|
+
"""createUploadSession followed by sequential ranged PUTs. Used at 4 MiB and above."""
|
|
244
|
+
created = await transport.json(
|
|
245
|
+
"POST", build_url(to_session_path(path), version), operation="upload"
|
|
246
|
+
)
|
|
247
|
+
upload_url = (created.get("body") or {}).get("uploadUrl")
|
|
248
|
+
if not upload_url:
|
|
249
|
+
raise GraphError(0, "internalError", "the upload session response carried no uploadUrl")
|
|
250
|
+
|
|
251
|
+
offset = 0
|
|
252
|
+
last = None
|
|
253
|
+
with source.open("rb") as handle:
|
|
254
|
+
while offset < size:
|
|
255
|
+
handle.seek(offset)
|
|
256
|
+
chunk = handle.read(CHUNK_SIZE)
|
|
257
|
+
if not chunk:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
last = await _put_chunk(transport, upload_url, chunk, offset, size)
|
|
261
|
+
if not last.is_success:
|
|
262
|
+
return Transport.envelope(last) # raises with the Graph error
|
|
263
|
+
|
|
264
|
+
offset += len(chunk)
|
|
265
|
+
|
|
266
|
+
# The service's own account of progress wins over ours.
|
|
267
|
+
resume = _next_expected(last)
|
|
268
|
+
if resume is not None and resume != offset and resume < size:
|
|
269
|
+
offset = resume
|
|
270
|
+
|
|
271
|
+
if last is None:
|
|
272
|
+
raise GraphError(0, "invalidRequest", "the file is empty")
|
|
273
|
+
return Transport.envelope(last)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _put_chunk(transport: Transport, upload_url: str, chunk: bytes, offset: int, total: int):
|
|
277
|
+
"""Send one chunk, retrying it against a transient failure."""
|
|
278
|
+
response = None
|
|
279
|
+
for _ in range(_MAX_CHUNK_ATTEMPTS):
|
|
280
|
+
response = await transport.send(
|
|
281
|
+
"PUT",
|
|
282
|
+
upload_url,
|
|
283
|
+
headers={
|
|
284
|
+
"Content-Range": f"bytes {offset}-{offset + len(chunk) - 1}/{total}",
|
|
285
|
+
"Content-Type": "application/octet-stream",
|
|
286
|
+
},
|
|
287
|
+
content=chunk,
|
|
288
|
+
operation="upload",
|
|
289
|
+
)
|
|
290
|
+
if response.is_success or response.status_code not in (408, 429, 500, 502, 503, 504):
|
|
291
|
+
return response
|
|
292
|
+
return response
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _next_expected(response) -> Optional[int]:
|
|
296
|
+
"""Read the session's nextExpectedRanges, which is what makes a large upload resumable."""
|
|
297
|
+
try:
|
|
298
|
+
body = response.json()
|
|
299
|
+
except ValueError:
|
|
300
|
+
return None
|
|
301
|
+
if not isinstance(body, dict):
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
ranges = body.get("nextExpectedRanges")
|
|
305
|
+
if not ranges:
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
return int(str(ranges[0]).split("-", 1)[0])
|
|
309
|
+
except (ValueError, IndexError):
|
|
310
|
+
return None
|