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.
@@ -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