graphplug 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphplug/__init__.py ADDED
@@ -0,0 +1,378 @@
1
+ """Plug-and-play Microsoft Graph for Python.
2
+
3
+ Import one class, hand it credentials, call Graph::
4
+
5
+ import asyncio
6
+ from graphplug import GraphClient, Scopes
7
+
8
+ async def main():
9
+ async with GraphClient.from_env() as graph:
10
+ await graph.mail.send(to="alice@contoso.com", subject="Hi", body="Hello")
11
+
12
+ async for user in graph.paged("/users", select="id,mail"):
13
+ print(user["mail"])
14
+
15
+ asyncio.run(main())
16
+
17
+ Token acquisition and refresh are azure-identity's job; retry, throttling and redirects are
18
+ Microsoft's middleware. What this package adds is a surface you can use without reading the Graph
19
+ reference first, and a few guarantees the boundary enforces rather than asking you to remember.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import inspect
25
+ import os
26
+ from types import TracebackType
27
+ from typing import Any, AsyncIterator, Dict, List, Optional, Sequence, Type
28
+
29
+ from . import _auth, _operations
30
+ from ._errors import GraphError
31
+ from ._http import DEFAULT_MAX_CONCURRENCY, Transport
32
+ from ._request import build_url, odata, reject_authorization
33
+ from ._resources import Calendar, Files, Mail, Teams, Users
34
+ from ._scopes import Scopes
35
+
36
+ __all__ = ["GraphClient", "GraphError", "PendingSignIn", "Scopes"]
37
+
38
+ __version__ = "0.2.0"
39
+
40
+
41
+ class PendingSignIn:
42
+ """A sign-in waiting on a human.
43
+
44
+ ``begin`` has returned what the person must see or do; ``complete`` waits for them.
45
+ """
46
+
47
+ def __init__(self, flow: Any, begun: Dict[str, Any], build: Any) -> None:
48
+ self._flow = flow
49
+ self._build = build
50
+ self._settled = False
51
+
52
+ #: Device code: the code the person types.
53
+ self.user_code: Optional[str] = begun.get("userCode")
54
+ #: Device code: where they type it.
55
+ self.verification_uri: Optional[str] = begun.get("verificationUri")
56
+ #: Device code: Microsoft's own instruction text, suitable for printing verbatim.
57
+ self.message: Optional[str] = begun.get("message")
58
+ #: Authorization code: the URL to open in a browser.
59
+ self.authorize_url: Optional[str] = begun.get("authorizeUrl")
60
+ #: Authorization code: the anti-forgery value the redirect must echo back.
61
+ self.state: Optional[str] = begun.get("state")
62
+ self.expires_in: int = int(begun.get("expiresInSeconds", 0))
63
+
64
+ async def complete(self, **completion: Any) -> "GraphClient":
65
+ """Block until the person finishes, then return a ready client."""
66
+ client = await self._build(self._flow, completion)
67
+ self._settled = True
68
+ return client
69
+
70
+ async def cancel(self) -> None:
71
+ """Abandon the sign-in and release whatever it was holding."""
72
+ if not self._settled:
73
+ self._settled = True
74
+ cancel = getattr(self._flow, "cancel", None)
75
+ if cancel is not None:
76
+ await cancel()
77
+
78
+ def __repr__(self) -> str:
79
+ what = self.user_code or self.authorize_url or "pending"
80
+ return f"<PendingSignIn {what!r}>"
81
+
82
+
83
+ class GraphClient:
84
+ """One authenticated session.
85
+
86
+ Once a client exists, nothing about the request surface depends on how it was authenticated, so
87
+ a script switches between access models by changing one constructor call.
88
+
89
+ Not safe to share across event loops. Within one loop it is safe to use concurrently, and
90
+ concurrency is bounded internally so Graph is not overwhelmed.
91
+ """
92
+
93
+ def __init__(self, transport: Transport) -> None:
94
+ self._transport = transport
95
+
96
+ #: Messages in the signed-in user's mailbox.
97
+ self.mail = Mail(self)
98
+ #: Events on the signed-in user's calendar.
99
+ self.calendar = Calendar(self)
100
+ #: Files in the signed-in user's drive.
101
+ self.files = Files(self)
102
+ #: Teams, channels, channel messages and chats.
103
+ self.teams = Teams(self)
104
+ #: People in the directory, and the signed-in person.
105
+ self.users = Users(self)
106
+
107
+ # ── application-level access ─────────────────────────────────────────────
108
+
109
+ @classmethod
110
+ def app_only(
111
+ cls,
112
+ tenant_id: str,
113
+ client_id: str,
114
+ client_secret: str,
115
+ scopes: Optional[Sequence[str]] = None,
116
+ authority_host: Optional[str] = None,
117
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
118
+ _client: Any = None,
119
+ ) -> "GraphClient":
120
+ """Act as the application itself, with admin-consented application permissions.
121
+
122
+ These are tenant-wide: ``Mail.Read`` as an application permission reads every mailbox.
123
+
124
+ Building a client contacts nothing. The credential is constructed locally and the first
125
+ token is fetched on the first request, so a bad secret surfaces then rather than here.
126
+ """
127
+ credential = _auth.app_only_credential(
128
+ tenant_id, client_id, client_secret, authority_host
129
+ )
130
+ return cls(Transport(
131
+ credential,
132
+ scopes or (_auth.DEFAULT_SCOPE,),
133
+ max_concurrency=max_concurrency,
134
+ client=_client,
135
+ ))
136
+
137
+ @classmethod
138
+ def from_env(cls, **overrides: Any) -> "GraphClient":
139
+ """Application-level access from ``AZURE_TENANT_ID`` / ``_CLIENT_ID`` / ``_CLIENT_SECRET``."""
140
+ names = ("AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET")
141
+ missing = [name for name in names if not os.environ.get(name)]
142
+ if missing:
143
+ raise GraphError(
144
+ 0, "invalidRequest", f"missing environment variables: {', '.join(missing)}"
145
+ )
146
+
147
+ tenant, client, secret = (os.environ[name] for name in names)
148
+ return cls.app_only(tenant, client, secret, **overrides)
149
+
150
+ @classmethod
151
+ def from_credential(
152
+ cls,
153
+ credential: Any,
154
+ scopes: Optional[Sequence[str]] = None,
155
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
156
+ _client: Any = None,
157
+ ) -> "GraphClient":
158
+ """Use any azure-identity credential, or anything else with a ``get_token``.
159
+
160
+ The extension point for the flows this package does not construct itself -- managed
161
+ identity, a certificate, on-behalf-of, a chained credential, or your own. They need no
162
+ support here because nothing above the transport knows how the token was obtained::
163
+
164
+ from azure.identity.aio import ManagedIdentityCredential
165
+ graph = GraphClient.from_credential(ManagedIdentityCredential())
166
+
167
+ A **synchronous** credential is accepted too and is run on a worker thread. Both spellings
168
+ exist in azure-identity for every flow, the async one is easy to miss, and getting it wrong
169
+ would otherwise fail at the first request with an error about an un-awaited coroutine
170
+ rather than about the credential.
171
+
172
+ The credential stays yours: closing the client leaves it open, since it may be shared.
173
+ """
174
+ if credential is None or not callable(getattr(credential, "get_token", None)):
175
+ raise GraphError(
176
+ 0, "invalidRequest", "'credential' must have a callable 'get_token'"
177
+ )
178
+
179
+ if not inspect.iscoroutinefunction(credential.get_token):
180
+ credential = _auth._SyncCredentialAdapter(credential)
181
+
182
+ return cls(Transport(
183
+ credential,
184
+ scopes or (_auth.DEFAULT_SCOPE,),
185
+ max_concurrency=max_concurrency,
186
+ client=_client,
187
+ owns_credential=False,
188
+ ))
189
+
190
+ # ── delegated access ─────────────────────────────────────────────────────
191
+
192
+ @classmethod
193
+ async def begin_device_code(
194
+ cls,
195
+ tenant_id: str,
196
+ client_id: str,
197
+ scopes: Sequence[str],
198
+ authority_host: Optional[str] = None,
199
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
200
+ _client: Any = None,
201
+ ) -> PendingSignIn:
202
+ """Start a device code sign-in and return at once, so you can render your own prompt."""
203
+ flow, begun = await _auth.device_code_begin(
204
+ tenant_id, client_id, scopes, authority_host
205
+ )
206
+
207
+ async def build(pending: Any, _completion: Dict[str, Any]) -> "GraphClient":
208
+ credential = await pending.complete()
209
+ return cls(Transport(
210
+ credential, pending.scopes, max_concurrency=max_concurrency, client=_client
211
+ ))
212
+
213
+ return PendingSignIn(flow, begun, build)
214
+
215
+ @classmethod
216
+ async def device_code(
217
+ cls,
218
+ tenant_id: str,
219
+ client_id: str,
220
+ scopes: Sequence[str],
221
+ authority_host: Optional[str] = None,
222
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
223
+ ) -> "GraphClient":
224
+ """Sign a person in by device code, printing the code and waiting for them."""
225
+ pending = await cls.begin_device_code(
226
+ tenant_id, client_id, scopes, authority_host, max_concurrency
227
+ )
228
+ print(pending.message or f"Visit {pending.verification_uri} and enter {pending.user_code}")
229
+
230
+ try:
231
+ return await pending.complete()
232
+ except BaseException:
233
+ await pending.cancel()
234
+ raise
235
+
236
+ @classmethod
237
+ async def interactive(
238
+ cls,
239
+ tenant_id: str,
240
+ client_id: str,
241
+ scopes: Sequence[str],
242
+ redirect_uri: str = "http://localhost:8400",
243
+ authority_host: Optional[str] = None,
244
+ timeout_seconds: float = _auth.SIGN_IN_WINDOW_SECONDS,
245
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
246
+ ) -> "GraphClient":
247
+ """Open a browser, catch the redirect on loopback, and return a ready client.
248
+
249
+ The redirect URI must match the app registration character for character; a registered
250
+ ``http://localhost:8400`` and a supplied ``http://localhost:8400/`` are different values to
251
+ Entra and produce AADSTS50011.
252
+ """
253
+ scopes = _auth.require_delegated_scopes(scopes)
254
+ verifier, challenge = _auth.pkce_pair()
255
+ state = _auth.new_state()
256
+
257
+ url = _auth.authorization_url(
258
+ tenant_id, client_id, scopes, redirect_uri, challenge, state, authority_host
259
+ )
260
+
261
+ def open_sign_in() -> None:
262
+ if not _auth.open_browser(url):
263
+ print(f"Open this URL to sign in:\n{url}")
264
+
265
+ # The browser opens only once the listener is bound, so a redirect cannot beat it.
266
+ redirect = await _auth.wait_for_redirect(redirect_uri, timeout_seconds, open_sign_in)
267
+
268
+ if "error" in redirect:
269
+ raise GraphError(
270
+ 0, "signInDeclined", redirect.get("error_description", redirect["error"])
271
+ )
272
+ # Validated here rather than by the caller, so the check cannot be skipped.
273
+ if redirect.get("state") != state:
274
+ raise GraphError(0, "stateMismatch", "the redirect state did not match the one issued")
275
+
276
+ credential = await _auth.exchange_code(
277
+ tenant_id, client_id, scopes, redirect_uri, redirect.get("code", ""),
278
+ verifier, authority_host,
279
+ )
280
+ return cls(Transport(credential, scopes, max_concurrency=max_concurrency))
281
+
282
+ # ── requests ─────────────────────────────────────────────────────────────
283
+
284
+ async def request(
285
+ self,
286
+ method: str,
287
+ path: str,
288
+ version: Optional[str] = None,
289
+ body: Any = None,
290
+ headers: Optional[Dict[str, str]] = None,
291
+ **options: Any,
292
+ ) -> Dict[str, Any]:
293
+ """Issue one request and return the whole envelope, including ``nextLink``.
294
+
295
+ ``path`` may be a relative Graph path or a full URL; when it is a URL, ``version`` and the
296
+ OData options are ignored because the URL already carries them.
297
+ """
298
+ url = build_url(path, version, odata(options))
299
+ return await self._transport.json(
300
+ method, url, headers=reject_authorization(headers), body=body
301
+ )
302
+
303
+ async def get(self, path: str, **options: Any) -> Any:
304
+ """``GET`` and return the response body."""
305
+ return (await self.request("GET", path, **options)).get("body")
306
+
307
+ async def post(self, path: str, body: Any = None, **options: Any) -> Any:
308
+ return (await self.request("POST", path, body=body, **options)).get("body")
309
+
310
+ async def patch(self, path: str, body: Any = None, **options: Any) -> Any:
311
+ return (await self.request("PATCH", path, body=body, **options)).get("body")
312
+
313
+ async def delete(self, path: str, **options: Any) -> Any:
314
+ return (await self.request("DELETE", path, **options)).get("body")
315
+
316
+ def paged(
317
+ self,
318
+ path: str,
319
+ version: Optional[str] = None,
320
+ headers: Optional[Dict[str, str]] = None,
321
+ **options: Any,
322
+ ) -> AsyncIterator[Dict[str, Any]]:
323
+ """Walk every page, yielding items.
324
+
325
+ An async generator, so nothing buffers the whole collection and abandoning it half way
326
+ leaks nothing.
327
+ """
328
+ return _operations.paged(
329
+ self._transport,
330
+ build_url(path, version, odata(options)),
331
+ reject_authorization(headers) or None,
332
+ )
333
+
334
+ async def batch(
335
+ self, requests: Sequence[_operations.BatchRequest], version: Optional[str] = None
336
+ ) -> List[Dict[str, Any]]:
337
+ """Send many requests as one call.
338
+
339
+ Split at Graph's limit of twenty, dispatched concurrently, and returned in the order you
340
+ sent them. A failing sub-request is reported in place with its own ``status`` rather than
341
+ raised -- one failure must not discard nineteen successes.
342
+ """
343
+ return await _operations.batch(self._transport, requests, version)
344
+
345
+ # ── files ────────────────────────────────────────────────────────────────
346
+
347
+ async def download(
348
+ self, path: str, dest_path: str, version: Optional[str] = None, **options: Any
349
+ ) -> Dict[str, Any]:
350
+ """Stream a response straight to disk. The directory must already exist."""
351
+ url = build_url(path, version, odata(options))
352
+ return await _operations.download(self._transport, url, dest_path)
353
+
354
+ async def upload(
355
+ self, path: str, source_path: str, version: Optional[str] = None
356
+ ) -> Dict[str, Any]:
357
+ """Send a local file, switching to a resumable session above 4 MiB on its own."""
358
+ return await _operations.upload(self._transport, path, source_path, version)
359
+
360
+ # ── lifetime ─────────────────────────────────────────────────────────────
361
+
362
+ async def aclose(self) -> None:
363
+ """Release the session. Safe to call twice."""
364
+ await self._transport.aclose()
365
+
366
+ async def __aenter__(self) -> "GraphClient":
367
+ return self
368
+
369
+ async def __aexit__(
370
+ self,
371
+ exc_type: Optional[Type[BaseException]],
372
+ exc: Optional[BaseException],
373
+ traceback: Optional[TracebackType],
374
+ ) -> None:
375
+ await self.aclose()
376
+
377
+ def __repr__(self) -> str:
378
+ return f"<GraphClient {'closed' if self._transport.closed else 'open'}>"