mgraphctl 0.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.

Potentially problematic release.


This version of mgraphctl might be problematic. Click here for more details.

Files changed (57) hide show
  1. mgraphctl/__init__.py +3 -0
  2. mgraphctl/__main__.py +3 -0
  3. mgraphctl/auth.py +395 -0
  4. mgraphctl/cli.py +259 -0
  5. mgraphctl/commands/__init__.py +71 -0
  6. mgraphctl/commands/api.py +164 -0
  7. mgraphctl/commands/calendar.py +491 -0
  8. mgraphctl/commands/chats.py +329 -0
  9. mgraphctl/commands/config_cmd.py +140 -0
  10. mgraphctl/commands/groups.py +65 -0
  11. mgraphctl/commands/mail.py +705 -0
  12. mgraphctl/commands/mailbox.py +176 -0
  13. mgraphctl/commands/meetings.py +294 -0
  14. mgraphctl/commands/onedrive.py +351 -0
  15. mgraphctl/commands/onenote.py +196 -0
  16. mgraphctl/commands/org.py +102 -0
  17. mgraphctl/commands/people.py +201 -0
  18. mgraphctl/commands/planner.py +268 -0
  19. mgraphctl/commands/presence.py +102 -0
  20. mgraphctl/commands/search.py +75 -0
  21. mgraphctl/commands/sharepoint.py +304 -0
  22. mgraphctl/commands/teams.py +235 -0
  23. mgraphctl/commands/todo.py +253 -0
  24. mgraphctl/commands/top.py +284 -0
  25. mgraphctl/config.py +385 -0
  26. mgraphctl/errors.py +276 -0
  27. mgraphctl/fixtures.py +149 -0
  28. mgraphctl/graph/__init__.py +1 -0
  29. mgraphctl/graph/calendar.py +313 -0
  30. mgraphctl/graph/chats.py +447 -0
  31. mgraphctl/graph/files.py +269 -0
  32. mgraphctl/graph/groups.py +55 -0
  33. mgraphctl/graph/mail.py +663 -0
  34. mgraphctl/graph/mailbox.py +89 -0
  35. mgraphctl/graph/meetings.py +269 -0
  36. mgraphctl/graph/onedrive.py +35 -0
  37. mgraphctl/graph/onenote.py +138 -0
  38. mgraphctl/graph/org.py +86 -0
  39. mgraphctl/graph/people.py +83 -0
  40. mgraphctl/graph/planner.py +415 -0
  41. mgraphctl/graph/presence.py +89 -0
  42. mgraphctl/graph/search.py +178 -0
  43. mgraphctl/graph/sharepoint.py +294 -0
  44. mgraphctl/graph/teams.py +240 -0
  45. mgraphctl/graph/todo.py +199 -0
  46. mgraphctl/graph/users.py +85 -0
  47. mgraphctl/html.py +127 -0
  48. mgraphctl/http.py +843 -0
  49. mgraphctl/odata.py +72 -0
  50. mgraphctl/render.py +627 -0
  51. mgraphctl/resolve.py +111 -0
  52. mgraphctl/token_store.py +196 -0
  53. mgraphctl-0.1.0.dist-info/METADATA +226 -0
  54. mgraphctl-0.1.0.dist-info/RECORD +57 -0
  55. mgraphctl-0.1.0.dist-info/WHEEL +4 -0
  56. mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
  57. mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mgraphctl/http.py ADDED
@@ -0,0 +1,843 @@
1
+ """The Graph HTTP client: retries, paging, batching, uploads, downloads (spec §5.1–5.4, §5.7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import email.utils
7
+ import json as jsonlib
8
+ import logging
9
+ import mimetypes
10
+ import os
11
+ import random
12
+ import re
13
+ import time
14
+ import uuid
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import dataclass, field, replace
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+ from urllib.parse import urlsplit
21
+
22
+ import httpx
23
+
24
+ from mgraphctl import config, errors, odata
25
+ from mgraphctl.errors import AuthError, GraphError, MsgraphError, UsageError
26
+
27
+ log = logging.getLogger("mgraphctl.http")
28
+
29
+
30
+ def timeouts(timeout_ms: int) -> tuple[httpx.Timeout, httpx.Timeout]:
31
+ """The normal and long-running timeouts for a configured budget (`MGRAPHCTL_TIMEOUT_MS`).
32
+
33
+ The long profile keeps its multiplier off whatever base is configured, so raising the
34
+ timeout for a slow link raises the upload and download budget with it.
35
+ """
36
+ seconds = timeout_ms / 1000
37
+ long_seconds = seconds * config.LONG_TIMEOUT_FACTOR
38
+ connect = config.CONNECT_TIMEOUT
39
+ return (
40
+ httpx.Timeout(connect=connect, read=seconds, write=seconds, pool=connect),
41
+ httpx.Timeout(connect=connect, read=long_seconds, write=long_seconds, pool=connect),
42
+ )
43
+
44
+
45
+ # The defaults; a client built from settings gets its own from `timeouts(timeout_ms)`.
46
+ TIMEOUT, LONG = timeouts(config.TIMEOUT_MS_DEFAULT)
47
+ BATCH_CHUNK = 20
48
+ JSON_HEADERS = {"Content-Type": "application/json"}
49
+ REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
50
+ LOG_BODY_LIMIT = 2048
51
+ # Replaying these is safe: the server either ignores the repeat or applies the same state.
52
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "DELETE", "PUT"})
53
+ # These fail before the request can have reached the server, so any method may be replayed.
54
+ CONNECT_PHASE_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
55
+ # Consecutive upload rounds that may fail to advance the offset before we call the session lost.
56
+ UPLOAD_STALL_LIMIT = 3
57
+
58
+ Expect = Literal["json", "bytes", "text", "none", "response"]
59
+
60
+ _TOKEN_IN_BODY = re.compile(r'("(?:access_token|refresh_token)"\s*:\s*")[^"]*"')
61
+ # An upload session's bearer lives in the uploadUrl query string. The scheme, host and path
62
+ # stay so a recorded session remains replayable (fixture keys drop the query on those PUTs).
63
+ _UPLOAD_URL_QUERY = re.compile(r'("uploadUrl"\s*:\s*"[^"?]*)\?[^"]*"')
64
+
65
+
66
+ def redact_body(text: str) -> str:
67
+ """Blank the OAuth tokens and the query string of any `uploadUrl` in a JSON body."""
68
+ return _UPLOAD_URL_QUERY.sub(r'\1?<redacted>"', _TOKEN_IN_BODY.sub(r'\1***"', text))
69
+
70
+
71
+ # Hosts the bearer token may ever be sent to: the host of the configured Graph base URLs,
72
+ # derived (not hard-coded) so this stays in lockstep with config.py. A caller can pass an
73
+ # absolute URL straight through GraphClient.url()/request() — e.g. one scraped from a
74
+ # document, or `api GET <url>` — and without this check that URL would receive the user's
75
+ # Graph token no matter which host it points at.
76
+ _BEARER_ALLOWED_HOSTS = frozenset(
77
+ urlsplit(base).hostname for base in (config.GRAPH_V1, config.GRAPH_BETA)
78
+ )
79
+
80
+
81
+ def _ensure_bearer_host(url: str) -> None:
82
+ """Refuse to attach the Authorization header to any URL off the Graph allow-list."""
83
+ parsed = urlsplit(url)
84
+ if parsed.scheme == "https" and parsed.hostname in _BEARER_ALLOWED_HOSTS:
85
+ return
86
+ allowed = ", ".join(f"https://{host}" for host in sorted(_BEARER_ALLOWED_HOSTS))
87
+ raise UsageError(
88
+ "USAGE",
89
+ f"refusing to send credentials to {parsed.hostname or url}; only {allowed} is allowed",
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class PageResult:
95
+ items: list[dict]
96
+ truncated: bool
97
+ pages: int
98
+ # Items retrieved from Graph before any client-side post-filter; None when nothing was
99
+ # filtered, in which case it equals len(items). Read it through `fetched_count`.
100
+ fetched: int | None = None
101
+ # The bound the fetch ran under: `--limit`, or the noun's cap under `--all`.
102
+ cap: int | None = None
103
+ # The server-side query that produced it, for a consumer reporting its own coverage.
104
+ query: dict[str, str] | None = None
105
+
106
+ @property
107
+ def fetched_count(self) -> int:
108
+ """How many items the fetch itself returned, post-filtering included."""
109
+ return len(self.items) if self.fetched is None else self.fetched
110
+
111
+
112
+ def with_routing(page: PageResult, **ids: str) -> PageResult:
113
+ """Stamp the ids that name where a message lives onto every item of `page`.
114
+
115
+ Graph omits them on messages fetched through their own collection — a chat message has no
116
+ `chatId`, a channel message no `channelIdentity` — which leaves a JSON consumer unable to
117
+ round-trip a message back to the thread it came from. An id Graph filled in is left alone;
118
+ one it sent as `null` (as it does for `chatId` on a channel message) is filled in here,
119
+ which is the whole point.
120
+ """
121
+ stamped = [{**item, **{k: v for k, v in ids.items() if not item.get(k)}} for item in page.items]
122
+ return replace(page, items=stamped)
123
+
124
+
125
+ def filter_page(page: PageResult, keep: Callable[[dict], bool]) -> PageResult:
126
+ """Apply a client-side post-filter to `page`, preserving what the fetch reported.
127
+
128
+ `truncated` and `pages` describe the fetch and are never recomputed from the filtered
129
+ list: a page trimmed below `--limit` can still be truncated. `fetched` keeps the
130
+ pre-filter count so the JSON envelope can report both (spec §6.2).
131
+ """
132
+ kept = [item for item in page.items if keep(item)]
133
+ if len(kept) == len(page.items):
134
+ return page
135
+ return PageResult(
136
+ items=kept,
137
+ truncated=page.truncated,
138
+ pages=page.pages,
139
+ fetched=page.fetched_count,
140
+ cap=page.cap,
141
+ query=page.query,
142
+ )
143
+
144
+
145
+ @dataclass
146
+ class PlannedRequest:
147
+ method: str
148
+ url: str
149
+ headers: dict[str, str] = field(default_factory=dict)
150
+ body: Any = None
151
+ note: str | None = None
152
+ file: Path | None = None
153
+ chunk_size: int | None = None
154
+ expect: Expect = "json"
155
+
156
+
157
+ Plan = list[PlannedRequest]
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class BatchRequest:
162
+ id: str
163
+ method: str
164
+ url: str
165
+ headers: dict[str, str] | None = None
166
+ body: Any = None
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class BatchResponse:
171
+ id: str
172
+ status: int
173
+ headers: dict[str, str]
174
+ body: Any
175
+ error: GraphError | None
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class DownloadResult:
180
+ path: Path
181
+ bytes: int
182
+ content_type: str
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class SearchResult:
187
+ hits: list[dict]
188
+ total: int | None
189
+ more: bool
190
+ # Hits the Search API returned before any client-side pass, and the bound it ran under.
191
+ fetched: int | None = None
192
+ cap: int | None = None
193
+ query: dict[str, str] | None = None
194
+
195
+ @property
196
+ def fetched_count(self) -> int:
197
+ return len(self.hits) if self.fetched is None else self.fetched
198
+
199
+
200
+ def _strip_query(url: str) -> str:
201
+ return url.split("?", 1)[0]
202
+
203
+
204
+ def _network_error(exc: Exception) -> MsgraphError:
205
+ return MsgraphError("NETWORK", f"{type(exc).__name__}: {exc}", hint=errors.HINTS["NETWORK"])
206
+
207
+
208
+ def _file_step(path: Path) -> dict:
209
+ content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
210
+ return {"$file": str(path), "bytes": path.stat().st_size, "contentType": content_type}
211
+
212
+
213
+ def _plan_body(step: PlannedRequest) -> Any:
214
+ if step.chunk_size is not None:
215
+ body: dict[str, Any] = {"createUploadSession": step.body}
216
+ if step.file is not None:
217
+ body["upload"] = _file_step(step.file)
218
+ body["chunkSize"] = step.chunk_size
219
+ return body
220
+ if step.file is not None:
221
+ return _file_step(step.file)
222
+ return step.body
223
+
224
+
225
+ def plan_to_json(plan: Plan) -> list[dict]:
226
+ """Render a plan as the `requests` array of the `--dry-run --json` document (§6.4)."""
227
+ steps: list[dict] = []
228
+ for step in plan:
229
+ rendered = {
230
+ "method": step.method,
231
+ "url": step.url,
232
+ "headers": dict(step.headers),
233
+ "body": _plan_body(step),
234
+ }
235
+ if step.note:
236
+ rendered["note"] = step.note
237
+ steps.append(rendered)
238
+ return steps
239
+
240
+
241
+ def plan_to_text(plan: Plan) -> str:
242
+ """Render a plan as the `--dry-run` text block (§6.4)."""
243
+ lines = ["DRY RUN — nothing sent"]
244
+ for number, step in enumerate(plan, 1):
245
+ lines.append(f"{number}. {step.method} {step.url}")
246
+ if step.note:
247
+ lines.append(f" # {step.note}")
248
+ lines.extend(f" {key}: {value}" for key, value in step.headers.items())
249
+ body = _plan_body(step)
250
+ if body is None:
251
+ continue
252
+ text = body if isinstance(body, str) else jsonlib.dumps(body, indent=2, ensure_ascii=False)
253
+ lines.extend(f" {line}" for line in text.splitlines())
254
+ return "\n".join(lines) + "\n"
255
+
256
+
257
+ class GraphClient:
258
+ """Every Graph call goes through here; service modules never touch httpx directly."""
259
+
260
+ sleep = staticmethod(time.sleep)
261
+
262
+ def __init__(
263
+ self,
264
+ token_provider: Callable[[bool], str],
265
+ *,
266
+ tz: str,
267
+ beta: bool = False,
268
+ debug: int = 0,
269
+ transport: httpx.BaseTransport | None = None,
270
+ retries: int = config.RETRIES_DEFAULT,
271
+ timeout_ms: int = config.TIMEOUT_MS_DEFAULT,
272
+ retry_base_ms: int = config.RETRY_BASE_MS_DEFAULT,
273
+ ) -> None:
274
+ self._token_provider = token_provider
275
+ self.tz = tz
276
+ self.beta = beta
277
+ self.debug = int(debug)
278
+ # `retries` counts the attempts after the first on a retryable status or a connection
279
+ # failure, so 0 disables those. The single 401 re-auth below is not one of them.
280
+ self.max_attempts = max(retries, 0) + 1
281
+ self.retry_base_ms = retry_base_ms
282
+ timeout, long = timeouts(timeout_ms)
283
+ self.long_timeout = long
284
+ # Both clients share one transport so record/replay also covers downloads and uploads.
285
+ self._api = httpx.Client(
286
+ timeout=timeout, follow_redirects=False, trust_env=True, transport=transport
287
+ )
288
+ self._plain = httpx.Client(
289
+ timeout=long,
290
+ follow_redirects=True,
291
+ max_redirects=5,
292
+ trust_env=True,
293
+ transport=transport,
294
+ )
295
+
296
+ # -- plumbing ---------------------------------------------------------------
297
+
298
+ def url(self, path: str, *, beta: bool | None = None) -> str:
299
+ """Absolute URL for an already-encoded path; absolute paths pass through verbatim."""
300
+ if path.startswith(("https://", "http://")):
301
+ return path
302
+ base = config.GRAPH_BETA if (self.beta if beta is None else beta) else config.GRAPH_V1
303
+ return base + path
304
+
305
+ def _build(
306
+ self,
307
+ client: httpx.Client,
308
+ method: str,
309
+ url: str,
310
+ *,
311
+ params: Mapping[str, Any] | None = None,
312
+ json_body: Any = None,
313
+ content: bytes | None = None,
314
+ headers: Mapping[str, str] | None = None,
315
+ outlook_tz: bool = False,
316
+ text_body: bool = False,
317
+ accept: str = "application/json",
318
+ timeout: httpx.Timeout | None = None,
319
+ ) -> httpx.Request:
320
+ if params:
321
+ url = odata.with_query(url, params)
322
+ hdrs: dict[str, str] = {
323
+ "Accept": accept,
324
+ "User-Agent": config.user_agent(),
325
+ "client-request-id": str(uuid.uuid4()),
326
+ }
327
+ prefer = []
328
+ if outlook_tz:
329
+ prefer.append(f'outlook.timezone="{self.tz}"')
330
+ if text_body:
331
+ prefer.append('outlook.body-content-type="text"')
332
+ if prefer:
333
+ hdrs["Prefer"] = ", ".join(prefer)
334
+ hdrs.update(headers or {})
335
+ if client is self._api:
336
+ _ensure_bearer_host(url)
337
+ hdrs["Authorization"] = f"Bearer {self._token_provider(False)}"
338
+ if json_body is not None:
339
+ content = jsonlib.dumps(json_body, ensure_ascii=False).encode()
340
+ hdrs.setdefault("Content-Type", "application/json")
341
+ extra = {} if timeout is None else {"timeout": timeout}
342
+ return client.build_request(method, url, content=content, headers=hdrs, **extra)
343
+
344
+ def _send(
345
+ self,
346
+ client: httpx.Client,
347
+ request: httpx.Request,
348
+ *,
349
+ stream: bool = False,
350
+ ) -> httpx.Response:
351
+ attempt, refreshed = 0, False
352
+ while True:
353
+ attempt += 1
354
+ started = time.monotonic()
355
+ try:
356
+ response = client.send(request, stream=stream)
357
+ except httpx.TransportError as exc:
358
+ # A non-idempotent write that failed after the request may have reached the
359
+ # server must not be replayed: /sendMail would send the mail twice.
360
+ retryable = request.method in IDEMPOTENT_METHODS or isinstance(
361
+ exc, CONNECT_PHASE_ERRORS
362
+ )
363
+ if not retryable or attempt >= self.max_attempts:
364
+ raise _network_error(exc) from exc
365
+ delay = self._backoff(attempt, None)
366
+ self._log_retry(delay, type(exc).__name__)
367
+ self.sleep(delay)
368
+ continue
369
+ self._log(client, request, response, attempt, started, streaming=stream)
370
+ if response.status_code == 401 and not refreshed and client is self._api:
371
+ refreshed = True
372
+ response.close()
373
+ new_token = self._token_provider(True)
374
+ if not new_token:
375
+ raise AuthError(
376
+ "UNAUTHORIZED",
377
+ "token refresh failed",
378
+ hint=errors.HINTS["UNAUTHORIZED"],
379
+ )
380
+ request.headers["Authorization"] = f"Bearer {new_token}"
381
+ continue
382
+ if response.status_code in config.RETRY_STATUSES and attempt < self.max_attempts:
383
+ delay = self._backoff(attempt, response.headers.get("Retry-After"))
384
+ response.close()
385
+ self._log_retry(delay, f"HTTP {response.status_code}")
386
+ self.sleep(delay)
387
+ continue
388
+ return response
389
+
390
+ def _backoff(self, attempt: int, retry_after: str | None) -> float:
391
+ """How long to wait before the next attempt: `Retry-After` when the server sent one.
392
+
393
+ Otherwise exponential from the configured base (`MGRAPHCTL_RETRY_BASE_MS`), capped,
394
+ with jitter proportional to that base so a small base stays small.
395
+ """
396
+ if retry_after:
397
+ value = retry_after.strip()
398
+ if value.isdigit():
399
+ return float(min(int(value), config.RETRY_AFTER_CAP))
400
+ with contextlib.suppress(TypeError, ValueError):
401
+ when = email.utils.parsedate_to_datetime(value)
402
+ seconds = (when - datetime.now(UTC)).total_seconds()
403
+ return max(0.0, min(seconds, config.RETRY_AFTER_CAP))
404
+ base = self.retry_base_ms / 1000
405
+ return min(2**attempt * base, config.BACKOFF_CAP) + random.uniform(0, base)
406
+
407
+ def _log_retry(self, delay: float, reason: str) -> None:
408
+ if self.debug >= 1:
409
+ log.debug("retry in %.1fs after %s", delay, reason)
410
+
411
+ def _log(
412
+ self,
413
+ client: httpx.Client,
414
+ request: httpx.Request,
415
+ response: httpx.Response,
416
+ attempt: int,
417
+ started: float,
418
+ *,
419
+ streaming: bool,
420
+ ) -> None:
421
+ if self.debug < 1:
422
+ return
423
+ # Pre-authenticated URLs (download targets, uploadUrl) carry a token in the query.
424
+ url = _strip_query(str(request.url)) if client is self._plain else str(request.url)
425
+ elapsed = int((time.monotonic() - started) * 1000)
426
+ line = f"{request.method} {url} -> {response.status_code} {elapsed}ms [attempt {attempt}]"
427
+ request_id = response.headers.get("request-id")
428
+ if request_id:
429
+ line = f"{line} [{request_id}]"
430
+ log.debug("%s", line)
431
+ prefer = request.headers.get("Prefer")
432
+ if prefer:
433
+ log.debug("Prefer: %s", prefer)
434
+ if self.debug >= 2 and not streaming and response.content:
435
+ body = response.text[:LOG_BODY_LIMIT]
436
+ log.debug("body: %s", redact_body(body))
437
+
438
+ # -- single requests --------------------------------------------------------
439
+
440
+ def request(
441
+ self,
442
+ method: str,
443
+ path: str,
444
+ *,
445
+ params: Mapping[str, Any] | None = None,
446
+ json: Any = None,
447
+ content: bytes | None = None,
448
+ headers: Mapping[str, str] | None = None,
449
+ beta: bool | None = None,
450
+ outlook_tz: bool = False,
451
+ text_body: bool = False,
452
+ expect: Expect = "json",
453
+ ) -> Any:
454
+ request = self._build(
455
+ self._api,
456
+ method,
457
+ self.url(path, beta=beta),
458
+ params=params,
459
+ json_body=json,
460
+ content=content,
461
+ headers=headers,
462
+ outlook_tz=outlook_tz,
463
+ text_body=text_body,
464
+ accept="*/*" if expect == "bytes" else "application/json",
465
+ )
466
+ response = self._send(self._api, request, stream=(expect == "response"))
467
+ if expect == "response":
468
+ if response.status_code >= 400:
469
+ response.read()
470
+ response.close()
471
+ raise self._error(response)
472
+ return response
473
+ if response.status_code == 401:
474
+ detail = self._error(response)
475
+ raise AuthError(
476
+ "UNAUTHORIZED",
477
+ detail.message,
478
+ hint=errors.HINTS["UNAUTHORIZED"],
479
+ request_id=detail.request_id,
480
+ )
481
+ if response.status_code >= 400:
482
+ raise self._error(response)
483
+ if expect == "none" or not response.content:
484
+ return None
485
+ if expect == "bytes":
486
+ return response.content
487
+ if expect == "text":
488
+ return response.text
489
+ return response.json()
490
+
491
+ @staticmethod
492
+ def _error(response: httpx.Response) -> GraphError:
493
+ return errors.graph_error_from_response(
494
+ response.status_code,
495
+ response.headers,
496
+ response.content,
497
+ _strip_query(str(response.url)),
498
+ )
499
+
500
+ def get(self, path: str, **kw: Any) -> Any:
501
+ return self.request("GET", path, **kw)
502
+
503
+ def post(self, path: str, **kw: Any) -> Any:
504
+ return self.request("POST", path, **kw)
505
+
506
+ def patch(self, path: str, **kw: Any) -> Any:
507
+ return self.request("PATCH", path, **kw)
508
+
509
+ def put(self, path: str, **kw: Any) -> Any:
510
+ return self.request("PUT", path, **kw)
511
+
512
+ def delete(self, path: str, **kw: Any) -> Any:
513
+ return self.request("DELETE", path, **kw)
514
+
515
+ # -- paging -----------------------------------------------------------------
516
+
517
+ def paginate(
518
+ self,
519
+ path: str,
520
+ *,
521
+ params: Mapping[str, Any] | None = None,
522
+ headers: Mapping[str, str] | None = None,
523
+ beta: bool | None = None,
524
+ outlook_tz: bool = False,
525
+ limit: int | None,
526
+ all_: bool,
527
+ cap: int,
528
+ page_size: int | None,
529
+ stop: Callable[[dict], bool] | None = None,
530
+ ) -> PageResult:
531
+ """Follow `@odata.nextLink` until `limit`/`cap`, or until `stop` says we are past the end.
532
+
533
+ `stop` suits a feed the server already orders the way the caller reads it: the first
534
+ item it accepts ends the fetch, and the page is then *not* truncated — everything the
535
+ caller asked for was seen. The item that tripped it is still returned, so the caller's
536
+ own post-filter (`filter_page`) decides what to keep.
537
+ """
538
+ if all_:
539
+ bound = cap
540
+ elif limit is None or limit < 1:
541
+ raise UsageError("USAGE", "--limit must be 1 or more")
542
+ else:
543
+ bound = min(limit, cap)
544
+ query = dict(params or {})
545
+ if page_size is not None:
546
+ query["$top"] = page_size
547
+ url = odata.with_query(path, query)
548
+ items: list[dict] = []
549
+ pages = 0
550
+ next_link: str | None = None
551
+ end: int | None = None # index of the first item `stop` accepted, once it has
552
+ while True:
553
+ payload = (
554
+ self.request("GET", url, headers=headers, beta=beta, outlook_tz=outlook_tz) or {}
555
+ )
556
+ pages += 1
557
+ batch = payload.get("value") or []
558
+ base = len(items)
559
+ items.extend(batch)
560
+ next_link = payload.get("@odata.nextLink")
561
+ if stop is not None:
562
+ end = next(
563
+ (base + i for i, item in enumerate(batch) if stop(item)),
564
+ None,
565
+ )
566
+ if end is not None:
567
+ break
568
+ if not next_link or len(items) >= bound:
569
+ break
570
+ url = next_link
571
+ return PageResult(
572
+ items=items[:bound],
573
+ # Truncation means results the caller asked for were left behind. Once `stop` has
574
+ # found the end, only the items ahead of it were ever asked for, so the count that
575
+ # matters is that one — not the whole fetch, whose tail is past the end anyway, and
576
+ # not the unfollowed nextLink, which leads further past it.
577
+ truncated=end > bound if end is not None else len(items) > bound or bool(next_link),
578
+ pages=pages,
579
+ cap=bound,
580
+ query={k: str(v) for k, v in query.items() if k.startswith("$")},
581
+ )
582
+
583
+ # -- $batch -----------------------------------------------------------------
584
+
585
+ @staticmethod
586
+ def _batch_sub(internal_id: str, request: BatchRequest) -> dict:
587
+ sub: dict[str, Any] = {"id": internal_id, "method": request.method, "url": request.url}
588
+ headers = dict(request.headers or {})
589
+ if request.body is not None:
590
+ headers.setdefault("Content-Type", "application/json")
591
+ if headers:
592
+ sub["headers"] = headers
593
+ if request.body is not None:
594
+ sub["body"] = request.body
595
+ return sub
596
+
597
+ def batch(
598
+ self, requests: list[BatchRequest], *, beta: bool | None = None
599
+ ) -> list[BatchResponse]:
600
+ results: dict[str, BatchResponse] = {}
601
+ for start in range(0, len(requests), BATCH_CHUNK):
602
+ chunk = requests[start : start + BATCH_CHUNK]
603
+ pending = {str(i): request for i, request in enumerate(chunk, 1)}
604
+ attempt = 0
605
+ while pending:
606
+ attempt += 1
607
+ payload = {"requests": [self._batch_sub(key, req) for key, req in pending.items()]}
608
+ response = self.post("/$batch", json=payload, beta=beta) or {}
609
+ retry_delays: list[float] = []
610
+ retrying: dict[str, BatchRequest] = {}
611
+ answered: set[str] = set()
612
+ for sub in response.get("responses") or []:
613
+ key = str(sub.get("id"))
614
+ request = pending.get(key)
615
+ if request is None:
616
+ continue
617
+ answered.add(key)
618
+ status = int(sub.get("status") or 0)
619
+ headers = {k.lower(): v for k, v in (sub.get("headers") or {}).items()}
620
+ if status == 429 and attempt < self.max_attempts:
621
+ retrying[key] = request
622
+ retry_delays.append(self._backoff(attempt, headers.get("retry-after")))
623
+ continue
624
+ error = None
625
+ if status >= 400:
626
+ error = errors.graph_error_from_batch(
627
+ status, headers, sub.get("body"), request.url
628
+ )
629
+ results[request.id] = BatchResponse(
630
+ request.id, status, headers, sub.get("body"), error
631
+ )
632
+ for key, request in pending.items():
633
+ if key in answered or key in retrying:
634
+ continue
635
+ missing = GraphError(
636
+ 502,
637
+ "HTTP_502",
638
+ "the $batch response carried no entry for this request",
639
+ url=request.url,
640
+ )
641
+ results[request.id] = BatchResponse(request.id, 502, {}, None, missing)
642
+ if not retrying:
643
+ break
644
+ self.sleep(max(retry_delays))
645
+ pending = retrying
646
+ return [results[request.id] for request in requests]
647
+
648
+ # -- upload / download ------------------------------------------------------
649
+
650
+ def upload_session(
651
+ self,
652
+ create_path: str,
653
+ create_body: dict,
654
+ file: Path,
655
+ *,
656
+ chunk_size: int,
657
+ beta: bool | None = None,
658
+ ) -> dict:
659
+ created = self.post(create_path, json=create_body, beta=beta) or {}
660
+ upload_url = created.get("uploadUrl")
661
+ if not upload_url:
662
+ raise MsgraphError(
663
+ "UPLOAD_SESSION_LOST",
664
+ "the createUploadSession response carried no uploadUrl",
665
+ hint=errors.HINTS["UPLOAD_SESSION_LOST"],
666
+ )
667
+ total = file.stat().st_size
668
+ offset = 0
669
+ stalled = 0
670
+ last: Any = {}
671
+ with file.open("rb") as handle:
672
+ while offset < total:
673
+ end = min(offset + chunk_size, total) - 1
674
+ handle.seek(offset)
675
+ chunk = handle.read(end - offset + 1)
676
+ request = self._build(
677
+ self._plain,
678
+ "PUT",
679
+ upload_url,
680
+ content=chunk,
681
+ headers={
682
+ "Content-Range": f"bytes {offset}-{end}/{total}",
683
+ "Content-Length": str(len(chunk)),
684
+ },
685
+ timeout=self.long_timeout,
686
+ )
687
+ response = self._send(self._plain, request)
688
+ if response.status_code == 404:
689
+ raise MsgraphError(
690
+ "UPLOAD_SESSION_LOST",
691
+ f"the upload session for {file.name} is gone (HTTP 404)",
692
+ hint=errors.HINTS["UPLOAD_SESSION_LOST"],
693
+ )
694
+ if response.status_code >= 400:
695
+ raise self._error(response)
696
+ last = response.json() if response.content else {}
697
+ ranges = last.get("nextExpectedRanges") if isinstance(last, dict) else None
698
+ previous = offset
699
+ offset = self._next_offset(ranges, file) if ranges else end + 1
700
+ # A server that keeps asking for the same range would loop forever.
701
+ stalled = stalled + 1 if offset <= previous else 0
702
+ if stalled >= UPLOAD_STALL_LIMIT:
703
+ raise MsgraphError(
704
+ "UPLOAD_SESSION_LOST",
705
+ f"the upload of {file.name} stopped advancing at byte {offset} "
706
+ f"after {stalled} rounds",
707
+ hint=errors.HINTS["UPLOAD_SESSION_LOST"],
708
+ )
709
+ return last if isinstance(last, dict) else {}
710
+
711
+ @staticmethod
712
+ def _next_offset(ranges: Any, file: Path) -> int:
713
+ """First byte of the server's next expected range, or the session is unusable."""
714
+ try:
715
+ return int(str(ranges[0]).split("-", 1)[0])
716
+ except (IndexError, KeyError, TypeError, ValueError) as exc:
717
+ raise MsgraphError(
718
+ "UPLOAD_SESSION_LOST",
719
+ f"the upload session for {file.name} returned an unreadable "
720
+ f"nextExpectedRanges: {ranges!r}",
721
+ hint=errors.HINTS["UPLOAD_SESSION_LOST"],
722
+ ) from exc
723
+
724
+ def download(self, path_or_url: str, dest: Path, *, beta: bool | None = None) -> DownloadResult:
725
+ request = self._build(
726
+ self._api,
727
+ "GET",
728
+ self.url(path_or_url, beta=beta),
729
+ accept="*/*",
730
+ timeout=self.long_timeout,
731
+ )
732
+ response = self._send(self._api, request, stream=True)
733
+ try:
734
+ if response.status_code in REDIRECT_STATUSES:
735
+ location = response.headers.get("Location")
736
+ if not location:
737
+ response.read()
738
+ raise self._error(response)
739
+ response.close()
740
+ request = self._build(
741
+ self._plain, "GET", location, accept="*/*", timeout=self.long_timeout
742
+ )
743
+ response = self._send(self._plain, request, stream=True)
744
+ if response.status_code >= 400:
745
+ response.read()
746
+ raise self._error(response)
747
+ content_type = response.headers.get("Content-Type", "application/octet-stream")
748
+ content_type = content_type.split(";", 1)[0].strip()
749
+ dest.parent.mkdir(parents=True, exist_ok=True)
750
+ part = dest.with_name(dest.name + ".part")
751
+ written = 0
752
+ try:
753
+ with part.open("wb") as handle:
754
+ for block in response.iter_bytes():
755
+ handle.write(block)
756
+ written += len(block)
757
+ except httpx.TransportError as exc:
758
+ part.unlink(missing_ok=True)
759
+ raise _network_error(exc) from exc
760
+ except BaseException:
761
+ part.unlink(missing_ok=True)
762
+ raise
763
+ finally:
764
+ response.close()
765
+ os.replace(part, dest)
766
+ return DownloadResult(path=dest, bytes=written, content_type=content_type)
767
+
768
+ # -- search -----------------------------------------------------------------
769
+
770
+ def search(
771
+ self,
772
+ entity_types: list[str],
773
+ query: str,
774
+ *,
775
+ size: int,
776
+ limit: int,
777
+ fields: list[str] | None = None,
778
+ enable_top_results: bool = False,
779
+ ) -> SearchResult:
780
+ hits: list[dict] = []
781
+ total: int | None = None
782
+ pending = False
783
+ offset = 0
784
+ while True:
785
+ body: dict[str, Any] = {
786
+ "entityTypes": list(entity_types),
787
+ "query": {"queryString": query},
788
+ "from": offset,
789
+ "size": size,
790
+ }
791
+ if fields:
792
+ body["fields"] = list(fields)
793
+ if enable_top_results:
794
+ body["enableTopResults"] = True
795
+ payload = self.post("/search/query", json={"requests": [body]}) or {}
796
+ pending = False
797
+ for value in payload.get("value") or []:
798
+ for container in value.get("hitsContainers") or []:
799
+ hits.extend(container.get("hits") or [])
800
+ if total is None and container.get("total") is not None:
801
+ total = container["total"]
802
+ if container.get("moreResultsAvailable"):
803
+ pending = True
804
+ offset += size
805
+ if not pending or len(hits) >= limit:
806
+ break
807
+ return SearchResult(hits=hits[:limit], total=total, more=pending or len(hits) > limit)
808
+
809
+ # -- planned requests -------------------------------------------------------
810
+
811
+ def execute(self, planned: PlannedRequest) -> Any:
812
+ """Run one step of a `Plan` (the non-dry-run half of §6.4)."""
813
+ if planned.chunk_size is not None:
814
+ if planned.file is None:
815
+ raise UsageError("USAGE", "an upload-session step needs a file")
816
+ return self.upload_session(
817
+ planned.url, planned.body or {}, planned.file, chunk_size=planned.chunk_size
818
+ )
819
+ kwargs: dict[str, Any] = {
820
+ "headers": dict(planned.headers) or None,
821
+ "expect": planned.expect,
822
+ }
823
+ if planned.file is not None:
824
+ kwargs["content"] = planned.file.read_bytes()
825
+ elif isinstance(planned.body, bytes):
826
+ kwargs["content"] = planned.body
827
+ elif isinstance(planned.body, str):
828
+ kwargs["content"] = planned.body.encode()
829
+ elif planned.body is not None:
830
+ kwargs["json"] = planned.body
831
+ return self.request(planned.method, planned.url, **kwargs)
832
+
833
+ # -- lifecycle --------------------------------------------------------------
834
+
835
+ def close(self) -> None:
836
+ self._api.close()
837
+ self._plain.close()
838
+
839
+ def __enter__(self) -> GraphClient:
840
+ return self
841
+
842
+ def __exit__(self, *exc: object) -> None:
843
+ self.close()