graphplug 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphplug/__init__.py +378 -0
- graphplug/_auth.py +390 -0
- graphplug/_errors.py +176 -0
- graphplug/_http.py +198 -0
- graphplug/_log.py +104 -0
- graphplug/_operations.py +310 -0
- graphplug/_request.py +135 -0
- graphplug/_resources/__init__.py +10 -0
- graphplug/_resources/base.py +70 -0
- graphplug/_resources/calendar.py +194 -0
- graphplug/_resources/files.py +149 -0
- graphplug/_resources/mail.py +196 -0
- graphplug/_resources/teams.py +146 -0
- graphplug/_resources/users.py +121 -0
- graphplug/_scopes.py +56 -0
- graphplug/py.typed +0 -0
- graphplug-0.2.0.dist-info/METADATA +234 -0
- graphplug-0.2.0.dist-info/RECORD +21 -0
- graphplug-0.2.0.dist-info/WHEEL +5 -0
- graphplug-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphplug-0.2.0.dist-info/top_level.txt +1 -0
graphplug/__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'}>"
|