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.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- 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()
|