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/_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})
@@ -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