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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Mail.
|
|
2
|
+
|
|
3
|
+
The point of this module is ``send``. Graph's ``sendMail`` payload is the most awkward thing in the
|
|
4
|
+
API to hand-build: recipients are objects inside objects, the body carries a content type, and an
|
|
5
|
+
attachment is a base64 blob with an ``@odata.type`` discriminator. Roughly twenty lines of nested
|
|
6
|
+
JSON become one call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import mimetypes
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, AsyncIterator, Dict, Iterable, List, Optional, Sequence, Union
|
|
16
|
+
|
|
17
|
+
from .._errors import GraphError
|
|
18
|
+
from .._request import segment
|
|
19
|
+
from .._scopes import Scopes
|
|
20
|
+
from .base import GraphResource
|
|
21
|
+
|
|
22
|
+
__all__ = ["Mail"]
|
|
23
|
+
|
|
24
|
+
Recipients = Union[str, Sequence[str], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _recipients(value: Recipients) -> List[Dict[str, Any]]:
|
|
28
|
+
"""One address or many, either way in the shape Graph expects."""
|
|
29
|
+
if not value:
|
|
30
|
+
return []
|
|
31
|
+
addresses = [value] if isinstance(value, str) else list(value)
|
|
32
|
+
return [{"emailAddress": {"address": address}} for address in addresses]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _attachment(path: Union[str, Path]) -> Dict[str, Any]:
|
|
36
|
+
source = Path(path)
|
|
37
|
+
if not source.is_file():
|
|
38
|
+
raise GraphError(0, "invalidRequest", f"attachment '{source}' does not exist")
|
|
39
|
+
|
|
40
|
+
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
|
|
41
|
+
return {
|
|
42
|
+
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
43
|
+
"name": source.name,
|
|
44
|
+
"contentType": content_type,
|
|
45
|
+
"contentBytes": base64.b64encode(source.read_bytes()).decode("ascii"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Mail(GraphResource):
|
|
50
|
+
"""Messages in the signed-in user's mailbox."""
|
|
51
|
+
|
|
52
|
+
path = "/me/messages"
|
|
53
|
+
scopes = Scopes.combine(Scopes.MAIL_READ_WRITE, Scopes.MAIL_SEND)
|
|
54
|
+
|
|
55
|
+
#: Graph rejects a message above roughly this size; beyond it an upload session is needed.
|
|
56
|
+
MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024
|
|
57
|
+
|
|
58
|
+
# ── sending ──────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def compose(
|
|
61
|
+
self,
|
|
62
|
+
to: Recipients,
|
|
63
|
+
subject: str,
|
|
64
|
+
body: str = "",
|
|
65
|
+
cc: Recipients = None,
|
|
66
|
+
bcc: Recipients = None,
|
|
67
|
+
html: bool = False,
|
|
68
|
+
attachments: Optional[Iterable[Union[str, Path]]] = None,
|
|
69
|
+
reply_to: Recipients = None,
|
|
70
|
+
) -> Dict[str, Any]:
|
|
71
|
+
"""Build the message payload without sending it.
|
|
72
|
+
|
|
73
|
+
Exposed because it is useful on its own -- for drafts, for batching, and for seeing exactly
|
|
74
|
+
what would go on the wire.
|
|
75
|
+
"""
|
|
76
|
+
if not to:
|
|
77
|
+
raise GraphError(0, "invalidRequest", "'to' is required")
|
|
78
|
+
|
|
79
|
+
message: Dict[str, Any] = {
|
|
80
|
+
"subject": subject,
|
|
81
|
+
"body": {"contentType": "HTML" if html else "Text", "content": body},
|
|
82
|
+
"toRecipients": _recipients(to),
|
|
83
|
+
}
|
|
84
|
+
if cc:
|
|
85
|
+
message["ccRecipients"] = _recipients(cc)
|
|
86
|
+
if bcc:
|
|
87
|
+
message["bccRecipients"] = _recipients(bcc)
|
|
88
|
+
if reply_to:
|
|
89
|
+
message["replyTo"] = _recipients(reply_to)
|
|
90
|
+
|
|
91
|
+
if attachments:
|
|
92
|
+
built = [_attachment(path) for path in attachments]
|
|
93
|
+
total = sum(len(item["contentBytes"]) for item in built)
|
|
94
|
+
if total > self.MAX_ATTACHMENT_BYTES:
|
|
95
|
+
raise GraphError(
|
|
96
|
+
0,
|
|
97
|
+
"invalidRequest",
|
|
98
|
+
f"attachments total {total} bytes; Graph rejects a message this large. "
|
|
99
|
+
"Upload to OneDrive and send a link instead.",
|
|
100
|
+
)
|
|
101
|
+
message["attachments"] = built
|
|
102
|
+
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
async def send(
|
|
106
|
+
self,
|
|
107
|
+
to: Recipients,
|
|
108
|
+
subject: str,
|
|
109
|
+
body: str = "",
|
|
110
|
+
cc: Recipients = None,
|
|
111
|
+
bcc: Recipients = None,
|
|
112
|
+
html: bool = False,
|
|
113
|
+
attachments: Optional[Iterable[Union[str, Path]]] = None,
|
|
114
|
+
reply_to: Recipients = None,
|
|
115
|
+
save_to_sent: bool = True,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Send a message. Returns nothing -- Graph answers ``sendMail`` with 202 and no body."""
|
|
118
|
+
await self._collection_action("sendMail", {
|
|
119
|
+
"message": self.compose(to, subject, body, cc, bcc, html, attachments, reply_to),
|
|
120
|
+
"saveToSentItems": save_to_sent,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
async def send_many(self, messages: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
124
|
+
"""Send many messages in as few round-trips as Graph allows.
|
|
125
|
+
|
|
126
|
+
Each entry is the keyword arguments ``send`` takes. Twenty go per round-trip, and the
|
|
127
|
+
batches run concurrently. Per-message failures come back in the results rather than
|
|
128
|
+
raising, so check each ``status``.
|
|
129
|
+
"""
|
|
130
|
+
requests = []
|
|
131
|
+
for fields in messages:
|
|
132
|
+
fields = dict(fields)
|
|
133
|
+
save = fields.pop("save_to_sent", True)
|
|
134
|
+
requests.append({
|
|
135
|
+
"method": "POST",
|
|
136
|
+
"url": "/me/sendMail",
|
|
137
|
+
"headers": {"Content-Type": "application/json"},
|
|
138
|
+
"body": {"message": self.compose(**fields), "saveToSentItems": save},
|
|
139
|
+
})
|
|
140
|
+
return await self._client.batch(requests)
|
|
141
|
+
|
|
142
|
+
# ── replying ─────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
async def reply(self, message_id: str, comment: str = "", reply_all: bool = False) -> None:
|
|
145
|
+
await self._action(message_id, "replyAll" if reply_all else "reply", {"comment": comment})
|
|
146
|
+
|
|
147
|
+
async def forward(self, message_id: str, to: Recipients, comment: str = "") -> None:
|
|
148
|
+
await self._action(message_id, "forward", {
|
|
149
|
+
"comment": comment,
|
|
150
|
+
"toRecipients": _recipients(to),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
# ── reading ──────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def inbox(
|
|
156
|
+
self,
|
|
157
|
+
unread_only: bool = False,
|
|
158
|
+
since: Optional[datetime] = None,
|
|
159
|
+
search: Optional[str] = None,
|
|
160
|
+
top: int = 50,
|
|
161
|
+
select: str = "id,subject,from,receivedDateTime,isRead,hasAttachments",
|
|
162
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
163
|
+
"""Walk the inbox newest first, narrowed however you like."""
|
|
164
|
+
options: Dict[str, Any] = {"select": select, "top": top}
|
|
165
|
+
|
|
166
|
+
# Graph refuses $orderby on a property that does not also lead $filter (InefficientFilter),
|
|
167
|
+
# so receivedDateTime comes first, and is bounded by nothing when there is no `since`.
|
|
168
|
+
filters = []
|
|
169
|
+
if since is not None:
|
|
170
|
+
moment = since if since.tzinfo else since.replace(tzinfo=timezone.utc)
|
|
171
|
+
filters.append(f"receivedDateTime ge {moment.astimezone(timezone.utc):%Y-%m-%dT%H:%M:%SZ}")
|
|
172
|
+
if unread_only:
|
|
173
|
+
if not filters:
|
|
174
|
+
filters.append("receivedDateTime ge 1900-01-01T00:00:00Z")
|
|
175
|
+
filters.append("isRead eq false")
|
|
176
|
+
if filters:
|
|
177
|
+
options["filter"] = " and ".join(filters)
|
|
178
|
+
|
|
179
|
+
if search:
|
|
180
|
+
# Graph forbids $search with $filter or $orderby, so search wins and the rest goes.
|
|
181
|
+
options = {"select": select, "top": top, "search": f'"{search}"'}
|
|
182
|
+
else:
|
|
183
|
+
options["orderby"] = "receivedDateTime desc"
|
|
184
|
+
|
|
185
|
+
return self._client.paged("/me/mailFolders/inbox/messages", **options)
|
|
186
|
+
|
|
187
|
+
async def mark_read(self, message_id: str, read: bool = True) -> Dict[str, Any]:
|
|
188
|
+
return await self.update(message_id, {"isRead": read})
|
|
189
|
+
|
|
190
|
+
async def move(self, message_id: str, folder: str) -> Dict[str, Any]:
|
|
191
|
+
"""Move to a named well-known folder, or to a folder id."""
|
|
192
|
+
return await self._action(message_id, "move", {"destinationId": folder})
|
|
193
|
+
|
|
194
|
+
async def delete_many(self, message_ids: Sequence[str]) -> List[Dict[str, Any]]:
|
|
195
|
+
requests = [{"method": "DELETE", "url": f"{self.path}/{segment(i)}"} for i in message_ids]
|
|
196
|
+
return await self._client.batch(requests)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Teams: channels, channel messages and chats.
|
|
2
|
+
|
|
3
|
+
The job people actually want here is "post this into that channel", and Graph makes it a two-level
|
|
4
|
+
lookup before you can: a team id, then a channel id, then a message whose text has to be wrapped in
|
|
5
|
+
a body object with a content type. ``post`` is that, in one call.
|
|
6
|
+
|
|
7
|
+
Chats and channels are different endpoints with the same message shape, so both are here and the
|
|
8
|
+
shape is built once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from .._errors import GraphError
|
|
16
|
+
from .._scopes import Scopes
|
|
17
|
+
from .base import GraphResource
|
|
18
|
+
|
|
19
|
+
__all__ = ["Teams"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_FIELDS = "id,displayName,description"
|
|
22
|
+
|
|
23
|
+
MESSAGE_FIELDS = "id,createdDateTime,from,body,importance"
|
|
24
|
+
|
|
25
|
+
_IMPORTANCE = ("normal", "high", "urgent")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _message(text: str, html: bool, subject: Optional[str], importance: str) -> Dict[str, Any]:
|
|
29
|
+
"""The chatMessage shape, which channel posts, replies and chats all share."""
|
|
30
|
+
if not text:
|
|
31
|
+
raise GraphError(0, "invalidRequest", "'message' is required")
|
|
32
|
+
if importance not in _IMPORTANCE:
|
|
33
|
+
raise GraphError(
|
|
34
|
+
0, "invalidRequest", f"'importance' must be one of {', '.join(_IMPORTANCE)}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
payload: Dict[str, Any] = {
|
|
38
|
+
"body": {"contentType": "html" if html else "text", "content": text},
|
|
39
|
+
}
|
|
40
|
+
if subject:
|
|
41
|
+
payload["subject"] = subject
|
|
42
|
+
if importance != "normal":
|
|
43
|
+
payload["importance"] = importance
|
|
44
|
+
return payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Teams(GraphResource):
|
|
48
|
+
"""Teams, their channels, and the messages in both.
|
|
49
|
+
|
|
50
|
+
Delegated access only in practice. Reading channel messages with *application* permissions is
|
|
51
|
+
one of Graph's protected APIs: Microsoft has to approve the app first, and until they do the
|
|
52
|
+
call returns 403 no matter what consent the tenant has granted. Posting as a user works
|
|
53
|
+
normally.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
path = "/teams"
|
|
57
|
+
scopes = Scopes.combine(Scopes.TEAM_READ_BASIC, Scopes.CHANNEL_MESSAGE_SEND)
|
|
58
|
+
|
|
59
|
+
# ── finding your way ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def mine(self, select: str = DEFAULT_FIELDS) -> AsyncIterator[Dict[str, Any]]:
|
|
62
|
+
"""Teams the signed-in person belongs to."""
|
|
63
|
+
return self._client.paged("/me/joinedTeams", select=select)
|
|
64
|
+
|
|
65
|
+
def channels(
|
|
66
|
+
self, team_id: str, select: str = DEFAULT_FIELDS
|
|
67
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
68
|
+
"""Channels in a team."""
|
|
69
|
+
return self._client.paged(f"/teams/{team_id}/channels", select=select)
|
|
70
|
+
|
|
71
|
+
async def channel_by_name(self, team_id: str, name: str) -> Dict[str, Any]:
|
|
72
|
+
"""Find a channel by its display name, so a caller need not carry channel ids around."""
|
|
73
|
+
async for channel in self.channels(team_id):
|
|
74
|
+
if channel.get("displayName", "").casefold() == name.casefold():
|
|
75
|
+
return channel
|
|
76
|
+
raise GraphError(0, "itemNotFound", f"team '{team_id}' has no channel named '{name}'")
|
|
77
|
+
|
|
78
|
+
def members(self, team_id: str) -> AsyncIterator[Dict[str, Any]]:
|
|
79
|
+
"""Who is in a team."""
|
|
80
|
+
return self._client.paged(f"/teams/{team_id}/members")
|
|
81
|
+
|
|
82
|
+
# ── channel messages ─────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def post(
|
|
85
|
+
self,
|
|
86
|
+
team_id: str,
|
|
87
|
+
channel_id: str,
|
|
88
|
+
message: str,
|
|
89
|
+
html: bool = False,
|
|
90
|
+
subject: Optional[str] = None,
|
|
91
|
+
importance: str = "normal",
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Post a message to a channel. Returns the created message, including its id."""
|
|
94
|
+
return await self._client.post(
|
|
95
|
+
f"/teams/{team_id}/channels/{channel_id}/messages",
|
|
96
|
+
body=_message(message, html, subject, importance),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def reply(
|
|
100
|
+
self,
|
|
101
|
+
team_id: str,
|
|
102
|
+
channel_id: str,
|
|
103
|
+
message_id: str,
|
|
104
|
+
message: str,
|
|
105
|
+
html: bool = False,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Reply in an existing channel thread.
|
|
108
|
+
|
|
109
|
+
Graph has no "reply to a reply": every reply attaches to the thread's root message, so
|
|
110
|
+
``message_id`` is the id ``post`` returned.
|
|
111
|
+
"""
|
|
112
|
+
return await self._client.post(
|
|
113
|
+
f"/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies",
|
|
114
|
+
body=_message(message, html, None, "normal"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def messages(
|
|
118
|
+
self, team_id: str, channel_id: str, top: int = 50, select: str = MESSAGE_FIELDS
|
|
119
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
120
|
+
"""Walk a channel's messages, newest first.
|
|
121
|
+
|
|
122
|
+
Replies are not included; they hang off each message's own ``replies`` collection.
|
|
123
|
+
"""
|
|
124
|
+
return self._client.paged(
|
|
125
|
+
f"/teams/{team_id}/channels/{channel_id}/messages", select=select, top=top
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ── chats ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def chats(self, select: str = "id,topic,chatType,lastUpdatedDateTime") -> AsyncIterator[Dict[str, Any]]:
|
|
131
|
+
"""The signed-in person's chats.
|
|
132
|
+
|
|
133
|
+
Unordered: Graph sorts chats only by ``lastMessagePreview/createdDateTime``, and refuses
|
|
134
|
+
``$orderby`` on anything else.
|
|
135
|
+
"""
|
|
136
|
+
return self._client.paged("/me/chats", select=select)
|
|
137
|
+
|
|
138
|
+
async def send_chat(self, chat_id: str, message: str, html: bool = False) -> Dict[str, Any]:
|
|
139
|
+
"""Send a message into an existing chat.
|
|
140
|
+
|
|
141
|
+
Starting a *new* chat is a different call -- POST ``/chats`` with its members -- and is
|
|
142
|
+
deliberately not wrapped here until something needs it.
|
|
143
|
+
"""
|
|
144
|
+
return await self._client.post(
|
|
145
|
+
f"/chats/{chat_id}/messages", body=_message(message, html, None, "normal")
|
|
146
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""People and the directory.
|
|
2
|
+
|
|
3
|
+
Two things make this endpoint awkward, and both live here rather than at the call site.
|
|
4
|
+
|
|
5
|
+
The first is that the signed-in person is ``/me`` while everyone else is ``/users/{id}``, so every
|
|
6
|
+
directory call has a fork in it. ``_who`` closes it: pass nobody and you get yourself.
|
|
7
|
+
|
|
8
|
+
The second is that Graph refuses its own most useful queries -- ``$search``, ``$count``,
|
|
9
|
+
``endswith``, ``$filter`` combined with ``$orderby`` -- unless the request carries
|
|
10
|
+
``ConsistencyLevel: eventual``. Without it the failure is a bare 400 that names none of this.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .._errors import GraphError
|
|
18
|
+
from .._request import segment
|
|
19
|
+
from .._scopes import Scopes
|
|
20
|
+
from .base import GraphResource
|
|
21
|
+
|
|
22
|
+
__all__ = ["Users"]
|
|
23
|
+
|
|
24
|
+
#: What Graph calls an advanced query. Cheap to send, and the queries below do not work without it.
|
|
25
|
+
ADVANCED_QUERY = {"ConsistencyLevel": "eventual"}
|
|
26
|
+
|
|
27
|
+
DEFAULT_FIELDS = "id,displayName,mail,userPrincipalName,jobTitle,department"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Users(GraphResource):
|
|
31
|
+
"""People in the directory, and the signed-in person.
|
|
32
|
+
|
|
33
|
+
Works under both access models. Under application-level access ``/me`` does not exist, so the
|
|
34
|
+
methods that default to it need a user id.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
path = "/users"
|
|
38
|
+
scopes = Scopes.USER_READ_ALL
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _who(user: Optional[str]) -> str:
|
|
42
|
+
"""``/me`` for the signed-in person, ``/users/{id}`` for anyone else."""
|
|
43
|
+
if user is None or user == "me":
|
|
44
|
+
return "/me"
|
|
45
|
+
return f"/users/{segment(user)}"
|
|
46
|
+
|
|
47
|
+
# ── the signed-in person ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async def me(self, select: str = DEFAULT_FIELDS) -> Dict[str, Any]:
|
|
50
|
+
"""Who am I. Delegated access only -- there is no user under app permissions."""
|
|
51
|
+
return await self._client.get("/me", select=select)
|
|
52
|
+
|
|
53
|
+
# ── finding someone ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def find(
|
|
56
|
+
self, query: str, top: int = 25, select: str = DEFAULT_FIELDS
|
|
57
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
58
|
+
"""Search names and addresses for a fragment.
|
|
59
|
+
|
|
60
|
+
Graph's ``$search`` on users matches per field, so the term is applied to both the display
|
|
61
|
+
name and the mail address -- searching "smith" should find someone whether it is their
|
|
62
|
+
surname or their address.
|
|
63
|
+
"""
|
|
64
|
+
if not query:
|
|
65
|
+
raise GraphError(0, "invalidRequest", "'query' is required")
|
|
66
|
+
|
|
67
|
+
term = query.replace('"', "")
|
|
68
|
+
return self._client.paged(
|
|
69
|
+
self.path,
|
|
70
|
+
headers=dict(ADVANCED_QUERY),
|
|
71
|
+
search=f'"displayName:{term}" OR "mail:{term}"',
|
|
72
|
+
select=select,
|
|
73
|
+
top=top,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def by_email(self, address: str, select: str = DEFAULT_FIELDS) -> Dict[str, Any]:
|
|
77
|
+
"""Look someone up by address.
|
|
78
|
+
|
|
79
|
+
Not the same as ``get(address)``: that resolves the *user principal name*, which is often
|
|
80
|
+
but not always the mail address. This filters on ``mail`` itself and raises if nobody
|
|
81
|
+
matches, so a wrong answer is not silently returned.
|
|
82
|
+
"""
|
|
83
|
+
quoted = address.replace("'", "''") # OData's escape; o'brien@ is a valid address
|
|
84
|
+
page = await self._client.get(
|
|
85
|
+
self.path, filter=f"mail eq '{quoted}'", select=select, top=2
|
|
86
|
+
)
|
|
87
|
+
found = (page or {}).get("value", [])
|
|
88
|
+
if not found:
|
|
89
|
+
raise GraphError(0, "itemNotFound", f"no user has the mail address '{address}'")
|
|
90
|
+
return found[0]
|
|
91
|
+
|
|
92
|
+
# ── the org chart ────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async def manager(self, user: Optional[str] = None) -> Dict[str, Any]:
|
|
95
|
+
"""Who this person reports to. Raises ``itemNotFound`` if nobody is set."""
|
|
96
|
+
return await self._client.get(f"{self._who(user)}/manager")
|
|
97
|
+
|
|
98
|
+
def reports(
|
|
99
|
+
self, user: Optional[str] = None, select: str = DEFAULT_FIELDS
|
|
100
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
101
|
+
"""Who reports to this person, directly."""
|
|
102
|
+
return self._client.paged(f"{self._who(user)}/directReports", select=select)
|
|
103
|
+
|
|
104
|
+
def groups(
|
|
105
|
+
self, user: Optional[str] = None, select: str = "id,displayName,mail,groupTypes"
|
|
106
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
107
|
+
"""Every group this person belongs to, directly."""
|
|
108
|
+
return self._client.paged(f"{self._who(user)}/memberOf", select=select)
|
|
109
|
+
|
|
110
|
+
# ── photo ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async def photo(
|
|
113
|
+
self, dest_path: str, user: Optional[str] = None, size: Optional[str] = None
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""Save a profile photo to disk.
|
|
116
|
+
|
|
117
|
+
``size`` is one of Graph's fixed sizes such as ``"96x96"``; omit it for the original. A
|
|
118
|
+
person with no photo is a 404, which arrives as ``GraphError`` with ``itemNotFound``.
|
|
119
|
+
"""
|
|
120
|
+
endpoint = f"{self._who(user)}/photo" if not size else f"{self._who(user)}/photos/{size}"
|
|
121
|
+
return await self._client.download(f"{endpoint}/$value", dest_path)
|
graphplug/_scopes.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Named Graph permissions.
|
|
2
|
+
|
|
3
|
+
Plug and play means not having to know that sending mail needs ``Mail.Send``. Each resource
|
|
4
|
+
declares the scopes it uses, so a client can be built from the resources you intend to touch and a
|
|
5
|
+
missing permission produces an error that names what to grant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
__all__ = ["Scopes"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scopes:
|
|
16
|
+
"""Delegated permission names, grouped by what you are trying to do."""
|
|
17
|
+
|
|
18
|
+
# Identity
|
|
19
|
+
USER_READ: Tuple[str, ...] = ("User.Read",)
|
|
20
|
+
USER_READ_ALL: Tuple[str, ...] = ("User.Read.All",)
|
|
21
|
+
|
|
22
|
+
# Mail
|
|
23
|
+
MAIL_READ: Tuple[str, ...] = ("Mail.Read",)
|
|
24
|
+
MAIL_READ_WRITE: Tuple[str, ...] = ("Mail.ReadWrite",)
|
|
25
|
+
MAIL_SEND: Tuple[str, ...] = ("Mail.Send",)
|
|
26
|
+
|
|
27
|
+
# Calendar
|
|
28
|
+
CALENDARS_READ: Tuple[str, ...] = ("Calendars.Read",)
|
|
29
|
+
CALENDARS_READ_WRITE: Tuple[str, ...] = ("Calendars.ReadWrite",)
|
|
30
|
+
|
|
31
|
+
# Files
|
|
32
|
+
FILES_READ: Tuple[str, ...] = ("Files.Read",)
|
|
33
|
+
FILES_READ_WRITE: Tuple[str, ...] = ("Files.ReadWrite",)
|
|
34
|
+
#: Other people's drives and SharePoint document libraries, not just your own.
|
|
35
|
+
FILES_READ_WRITE_ALL: Tuple[str, ...] = ("Files.ReadWrite.All",)
|
|
36
|
+
|
|
37
|
+
# Teams
|
|
38
|
+
TEAM_READ_BASIC: Tuple[str, ...] = ("Team.ReadBasic.All",)
|
|
39
|
+
CHANNEL_MESSAGE_SEND: Tuple[str, ...] = ("ChannelMessage.Send",)
|
|
40
|
+
#: Reading channel messages is a protected API: Microsoft must approve the app first.
|
|
41
|
+
CHANNEL_MESSAGE_READ: Tuple[str, ...] = ("ChannelMessage.Read.All",)
|
|
42
|
+
CHAT_READ_WRITE: Tuple[str, ...] = ("Chat.ReadWrite",)
|
|
43
|
+
|
|
44
|
+
#: Everything the built-in resources can use. Convenient for a first run; narrow it afterwards.
|
|
45
|
+
EVERYTHING: Tuple[str, ...] = (
|
|
46
|
+
"User.Read", "User.Read.All",
|
|
47
|
+
"Mail.ReadWrite", "Mail.Send",
|
|
48
|
+
"Calendars.ReadWrite",
|
|
49
|
+
"Files.ReadWrite",
|
|
50
|
+
"Team.ReadBasic.All", "ChannelMessage.Send", "Chat.ReadWrite",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def combine(*groups: Sequence[str]) -> Tuple[str, ...]:
|
|
55
|
+
"""Merge scope groups, keeping order and dropping duplicates."""
|
|
56
|
+
return tuple(dict.fromkeys(scope for group in groups for scope in group))
|
graphplug/py.typed
ADDED
|
File without changes
|