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/_request.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Building requests and shaping responses.
|
|
2
|
+
|
|
3
|
+
Ported from the C# core's `GraphUrlBuilder`, `ResponseHeaderFilter` and `GraphOperation`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional
|
|
10
|
+
from urllib.parse import quote, urlencode
|
|
11
|
+
|
|
12
|
+
from ._errors import GraphError
|
|
13
|
+
|
|
14
|
+
__all__ = ["build_url", "odata", "segment", "allowlisted", "reject_authorization", "DEFAULT_VERSION"]
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://graph.microsoft.com"
|
|
17
|
+
DEFAULT_VERSION = "v1.0"
|
|
18
|
+
SUPPORTED_VERSIONS = (DEFAULT_VERSION, "beta")
|
|
19
|
+
|
|
20
|
+
#: What tells a whole URL apart from a Graph path: a scheme and ``://`` at the very start.
|
|
21
|
+
#:
|
|
22
|
+
#: Deliberately not ``urlsplit(path).scheme``, which reads "C:\\secrets" as scheme "c". Anchored at
|
|
23
|
+
#: the start because a Graph path may carry ``://`` further in -- a drive search for a URL, say.
|
|
24
|
+
#: The equivalent check in the C# core was the fix for a bug that broke every request on Linux
|
|
25
|
+
#: while passing on Windows.
|
|
26
|
+
_ABSOLUTE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
|
27
|
+
|
|
28
|
+
#: Left alone when an id or a drive path goes into a URL path. Everything else -- ``#``, ``?``,
|
|
29
|
+
#: ``%``, spaces -- is percent-encoded, or it would end the path early.
|
|
30
|
+
_PATH_SAFE = "/:@=+$,!"
|
|
31
|
+
|
|
32
|
+
#: OData parameters, spelled as Python keywords.
|
|
33
|
+
ODATA_KEYWORDS = ("select", "filter", "top", "skip", "expand", "orderby", "search", "count")
|
|
34
|
+
|
|
35
|
+
#: Response headers reach the caller through an allowlist, never a denylist: a denylist fails open
|
|
36
|
+
#: on whatever header Microsoft adds tomorrow. Authorization and WWW-Authenticate therefore cannot
|
|
37
|
+
#: escape even by accident.
|
|
38
|
+
_HEADER_ALLOWLIST = (
|
|
39
|
+
"request-id",
|
|
40
|
+
"client-request-id",
|
|
41
|
+
"Date",
|
|
42
|
+
"Retry-After",
|
|
43
|
+
"Content-Type",
|
|
44
|
+
"Location",
|
|
45
|
+
"ETag",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def odata(options: Mapping[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
"""Turn ``select=...`` into ``$select``, leaving anything else a literal parameter."""
|
|
51
|
+
query: Dict[str, Any] = {}
|
|
52
|
+
for name, value in options.items():
|
|
53
|
+
if value is None:
|
|
54
|
+
continue
|
|
55
|
+
query[f"${name}" if name in ODATA_KEYWORDS else name] = value
|
|
56
|
+
return query
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_url(
|
|
60
|
+
path: Optional[str],
|
|
61
|
+
version: Optional[str] = None,
|
|
62
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Build the request URL.
|
|
65
|
+
|
|
66
|
+
An absolute ``path`` is used verbatim and ``version`` and ``query`` are ignored, because the
|
|
67
|
+
URL already carries them. That is what makes ``@odata.nextLink`` echo-back and pre-authenticated
|
|
68
|
+
download URLs work with no special case.
|
|
69
|
+
"""
|
|
70
|
+
if not path or not path.strip():
|
|
71
|
+
raise GraphError(0, "invalidRequest", "'path' is required")
|
|
72
|
+
|
|
73
|
+
if _ABSOLUTE.match(path):
|
|
74
|
+
if not path.lower().startswith("https://"):
|
|
75
|
+
raise GraphError(0, "invalidRequest", "an absolute 'path' must use https")
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
resolved = _resolve_version(version)
|
|
79
|
+
suffix = path if path.startswith("/") else "/" + path
|
|
80
|
+
url = f"{BASE_URL}/{resolved}{suffix}"
|
|
81
|
+
|
|
82
|
+
if query:
|
|
83
|
+
# safe="$" keeps $select readable on the wire; Graph accepts either form.
|
|
84
|
+
encoded = urlencode({k: _literal(v) for k, v in query.items()}, safe="$")
|
|
85
|
+
url = f"{url}{'&' if '?' in url else '?'}{encoded}"
|
|
86
|
+
return url
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _literal(value: Any) -> str:
|
|
90
|
+
"""OData is case-sensitive about booleans: ``$count=true``, never ``True``."""
|
|
91
|
+
return str(value).lower() if isinstance(value, bool) else str(value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def segment(value: str) -> str:
|
|
95
|
+
"""Percent-encode an id or drive path for use inside a URL path.
|
|
96
|
+
|
|
97
|
+
A guest's user principal name carries ``#EXT#`` and a file may be called ``Q3 #1.xlsx``.
|
|
98
|
+
Unencoded, the ``#`` starts a fragment and the request silently goes to a different path.
|
|
99
|
+
"""
|
|
100
|
+
return quote(value, safe=_PATH_SAFE)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_version(version: Optional[str]) -> str:
|
|
104
|
+
if not version:
|
|
105
|
+
return DEFAULT_VERSION
|
|
106
|
+
if version not in SUPPORTED_VERSIONS:
|
|
107
|
+
raise GraphError(
|
|
108
|
+
0, "invalidRequest", f"'version' must be one of {', '.join(SUPPORTED_VERSIONS)}"
|
|
109
|
+
)
|
|
110
|
+
return version
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def allowlisted(headers: Mapping[str, str]) -> Dict[str, str]:
|
|
114
|
+
"""Only the named headers reach the caller."""
|
|
115
|
+
lowered = {name.lower(): value for name, value in headers.items()}
|
|
116
|
+
return {name: lowered[name.lower()] for name in _HEADER_ALLOWLIST if name.lower() in lowered}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def reject_authorization(headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
120
|
+
"""Forward caller headers as supplied, except Authorization.
|
|
121
|
+
|
|
122
|
+
This package owns authentication; a caller-supplied bearer token would silently bypass the
|
|
123
|
+
credential the client was built with.
|
|
124
|
+
"""
|
|
125
|
+
if not headers:
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
for name in headers:
|
|
129
|
+
if name.lower() == "authorization":
|
|
130
|
+
raise GraphError(
|
|
131
|
+
0,
|
|
132
|
+
"invalidRequest",
|
|
133
|
+
"'Authorization' may not be supplied; the client owns authentication",
|
|
134
|
+
)
|
|
135
|
+
return dict(headers)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Resource layer: the plug-and-play surface over the generic transport."""
|
|
2
|
+
|
|
3
|
+
from .base import GraphResource
|
|
4
|
+
from .calendar import Calendar
|
|
5
|
+
from .files import Files
|
|
6
|
+
from .mail import Mail
|
|
7
|
+
from .teams import Teams
|
|
8
|
+
from .users import Users
|
|
9
|
+
|
|
10
|
+
__all__ = ["GraphResource", "Calendar", "Files", "Mail", "Teams", "Users"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared behaviour for any Graph collection.
|
|
2
|
+
|
|
3
|
+
Every Graph collection supports the same operations over a different path, which is a real
|
|
4
|
+
variation point rather than a speculative one: a base class plus a thin subclass per resource
|
|
5
|
+
means adding a resource later is one class and nothing else moves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
from .._request import segment
|
|
13
|
+
|
|
14
|
+
__all__ = ["GraphResource"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphResource:
|
|
18
|
+
"""Base for a Graph collection. Subclasses set ``path`` and ``scopes``."""
|
|
19
|
+
|
|
20
|
+
#: The collection's path, e.g. ``/me/messages``.
|
|
21
|
+
path: str = ""
|
|
22
|
+
|
|
23
|
+
#: The delegated permissions this resource needs, for error messages and scope assembly.
|
|
24
|
+
scopes: Tuple[str, ...] = ()
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: Any) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
# ── the shared five ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def list(self, **options: Any) -> AsyncIterator[Dict[str, Any]]:
|
|
32
|
+
"""Every item, one page at a time. Returns an async generator."""
|
|
33
|
+
return self._client.paged(self.path, **options)
|
|
34
|
+
|
|
35
|
+
async def get(self, item_id: str, **options: Any) -> Dict[str, Any]:
|
|
36
|
+
return await self._client.get(f"{self.path}/{segment(item_id)}", **options)
|
|
37
|
+
|
|
38
|
+
async def create(self, body: Dict[str, Any], **options: Any) -> Dict[str, Any]:
|
|
39
|
+
return await self._client.post(self.path, body=body, **options)
|
|
40
|
+
|
|
41
|
+
async def update(self, item_id: str, body: Dict[str, Any], **options: Any) -> Dict[str, Any]:
|
|
42
|
+
return await self._client.patch(f"{self.path}/{segment(item_id)}", body=body, **options)
|
|
43
|
+
|
|
44
|
+
async def delete(self, item_id: str, **options: Any) -> None:
|
|
45
|
+
await self._client.delete(f"{self.path}/{segment(item_id)}", **options)
|
|
46
|
+
|
|
47
|
+
# ── the fast path ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async def get_many(
|
|
50
|
+
self, item_ids: Sequence[str], select: Optional[str] = None
|
|
51
|
+
) -> List[Dict[str, Any]]:
|
|
52
|
+
"""Fetch many items in as few round-trips as Graph allows.
|
|
53
|
+
|
|
54
|
+
Chunked into batches of twenty and dispatched concurrently, so two hundred lookups cost ten
|
|
55
|
+
round-trips rather than two hundred. Results come back in the order the ids were given.
|
|
56
|
+
"""
|
|
57
|
+
query = f"?$select={select}" if select else ""
|
|
58
|
+
requests = [{"method": "GET", "url": f"{self.path}/{segment(i)}{query}"} for i in item_ids]
|
|
59
|
+
return await self._client.batch(requests)
|
|
60
|
+
|
|
61
|
+
# ── helpers for subclasses ───────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async def _action(self, item_id: str, action: str, body: Any = None) -> Any:
|
|
64
|
+
"""POST to an action on one item, e.g. ``/me/messages/{id}/reply``."""
|
|
65
|
+
return await self._client.post(f"{self.path}/{segment(item_id)}/{action}", body=body)
|
|
66
|
+
|
|
67
|
+
async def _collection_action(self, action: str, body: Any = None) -> Any:
|
|
68
|
+
"""POST to an action on the collection's owner, e.g. ``/me/sendMail``."""
|
|
69
|
+
owner = self.path.rsplit("/", 1)[0] or "/me"
|
|
70
|
+
return await self._client.post(f"{owner}/{action}", body=body)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Calendar and meetings.
|
|
2
|
+
|
|
3
|
+
As with mail, the value here is the payload. A Teams meeting needs ``isOnlineMeeting`` paired with
|
|
4
|
+
``onlineMeetingProvider``; attendees are objects carrying a ``type``; and every time is a
|
|
5
|
+
``dateTime``/``timeZone`` pair rather than an ISO string. None of that is guessable from the call
|
|
6
|
+
site, so it lives here once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Any, AsyncIterator, Dict, Iterable, List, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
from .._errors import GraphError
|
|
15
|
+
from .._request import build_url, odata
|
|
16
|
+
from .._scopes import Scopes
|
|
17
|
+
from .base import GraphResource
|
|
18
|
+
|
|
19
|
+
__all__ = ["Calendar"]
|
|
20
|
+
|
|
21
|
+
Attendees = Union[str, Sequence[str], None]
|
|
22
|
+
|
|
23
|
+
_RESPONSES = {"accept", "decline", "tentativelyAccept"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _graph_time(moment: datetime, tz: str) -> Dict[str, str]:
|
|
27
|
+
"""Graph wants a naive local time plus a named zone, not an offset."""
|
|
28
|
+
if moment.tzinfo is not None:
|
|
29
|
+
moment = moment.astimezone(timezone.utc).replace(tzinfo=None)
|
|
30
|
+
tz = "UTC"
|
|
31
|
+
return {"dateTime": moment.isoformat(timespec="seconds"), "timeZone": tz}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _attendees(value: Attendees, kind: str = "required") -> List[Dict[str, Any]]:
|
|
35
|
+
if not value:
|
|
36
|
+
return []
|
|
37
|
+
addresses = [value] if isinstance(value, str) else list(value)
|
|
38
|
+
return [
|
|
39
|
+
{"emailAddress": {"address": address}, "type": kind}
|
|
40
|
+
for address in addresses
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Calendar(GraphResource):
|
|
45
|
+
"""Events on the signed-in user's calendar."""
|
|
46
|
+
|
|
47
|
+
path = "/me/events"
|
|
48
|
+
scopes = Scopes.CALENDARS_READ_WRITE
|
|
49
|
+
|
|
50
|
+
# ── scheduling ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def compose(
|
|
53
|
+
self,
|
|
54
|
+
subject: str,
|
|
55
|
+
start: datetime,
|
|
56
|
+
end: datetime,
|
|
57
|
+
attendees: Attendees = None,
|
|
58
|
+
optional_attendees: Attendees = None,
|
|
59
|
+
online: bool = False,
|
|
60
|
+
location: Optional[str] = None,
|
|
61
|
+
body: str = "",
|
|
62
|
+
html: bool = False,
|
|
63
|
+
timezone_name: str = "UTC",
|
|
64
|
+
reminder_minutes: Optional[int] = None,
|
|
65
|
+
all_day: bool = False,
|
|
66
|
+
) -> Dict[str, Any]:
|
|
67
|
+
"""Build the event payload without creating it."""
|
|
68
|
+
if end <= start:
|
|
69
|
+
raise GraphError(0, "invalidRequest", "'end' must be after 'start'")
|
|
70
|
+
|
|
71
|
+
event: Dict[str, Any] = {
|
|
72
|
+
"subject": subject,
|
|
73
|
+
"start": _graph_time(start, timezone_name),
|
|
74
|
+
"end": _graph_time(end, timezone_name),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
people = _attendees(attendees) + _attendees(optional_attendees, "optional")
|
|
78
|
+
if people:
|
|
79
|
+
event["attendees"] = people
|
|
80
|
+
if body:
|
|
81
|
+
event["body"] = {"contentType": "HTML" if html else "Text", "content": body}
|
|
82
|
+
if location:
|
|
83
|
+
event["location"] = {"displayName": location}
|
|
84
|
+
if reminder_minutes is not None:
|
|
85
|
+
event["reminderMinutesBeforeStart"] = reminder_minutes
|
|
86
|
+
event["isReminderOn"] = True
|
|
87
|
+
if all_day:
|
|
88
|
+
event["isAllDay"] = True
|
|
89
|
+
|
|
90
|
+
if online:
|
|
91
|
+
# Both fields are needed; isOnlineMeeting alone produces an event with no join link.
|
|
92
|
+
event["isOnlineMeeting"] = True
|
|
93
|
+
event["onlineMeetingProvider"] = "teamsForBusiness"
|
|
94
|
+
|
|
95
|
+
return event
|
|
96
|
+
|
|
97
|
+
async def schedule(self, **fields: Any) -> Dict[str, Any]:
|
|
98
|
+
"""Create an event. Takes everything ``compose`` takes.
|
|
99
|
+
|
|
100
|
+
With ``online=True`` the response carries ``onlineMeeting.joinUrl``.
|
|
101
|
+
"""
|
|
102
|
+
return await self.create(self.compose(**fields))
|
|
103
|
+
|
|
104
|
+
async def schedule_many(self, events: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
105
|
+
"""Create many events in as few round-trips as Graph allows."""
|
|
106
|
+
requests = [{
|
|
107
|
+
"method": "POST",
|
|
108
|
+
"url": self.path,
|
|
109
|
+
"headers": {"Content-Type": "application/json"},
|
|
110
|
+
"body": self.compose(**fields),
|
|
111
|
+
} for fields in events]
|
|
112
|
+
return await self._client.batch(requests)
|
|
113
|
+
|
|
114
|
+
# ── reading ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def upcoming(
|
|
117
|
+
self,
|
|
118
|
+
days: int = 7,
|
|
119
|
+
select: str = "id,subject,start,end,location,onlineMeeting,organizer,attendees",
|
|
120
|
+
top: int = 50,
|
|
121
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
122
|
+
"""Events in the next ``days``, soonest first.
|
|
123
|
+
|
|
124
|
+
Uses calendarView, which is the endpoint that expands recurring series -- listing /events
|
|
125
|
+
returns the series master instead of its occurrences.
|
|
126
|
+
"""
|
|
127
|
+
now = datetime.now(timezone.utc)
|
|
128
|
+
query = odata({
|
|
129
|
+
"select": select,
|
|
130
|
+
"top": top,
|
|
131
|
+
"orderby": "start/dateTime",
|
|
132
|
+
})
|
|
133
|
+
query["startDateTime"] = now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
134
|
+
query["endDateTime"] = (now + timedelta(days=days)).isoformat(
|
|
135
|
+
timespec="seconds").replace("+00:00", "Z")
|
|
136
|
+
|
|
137
|
+
return self._client.paged(build_url("/me/calendarView", None, query))
|
|
138
|
+
|
|
139
|
+
# ── responding ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async def respond(
|
|
142
|
+
self, event_id: str, response: str, comment: str = "", send_response: bool = True
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Accept, decline or tentatively accept an invitation."""
|
|
145
|
+
if response not in _RESPONSES:
|
|
146
|
+
raise GraphError(
|
|
147
|
+
0, "invalidRequest", f"'response' must be one of {', '.join(sorted(_RESPONSES))}"
|
|
148
|
+
)
|
|
149
|
+
await self._action(event_id, response, {
|
|
150
|
+
"comment": comment,
|
|
151
|
+
"sendResponse": send_response,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
async def cancel(self, event_id: str, comment: str = "") -> None:
|
|
155
|
+
"""Cancel a meeting you organise, notifying the attendees."""
|
|
156
|
+
await self._action(event_id, "cancel", {"comment": comment})
|
|
157
|
+
|
|
158
|
+
# ── finding a slot ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async def find_times(
|
|
161
|
+
self,
|
|
162
|
+
attendees: Attendees,
|
|
163
|
+
duration_minutes: int = 30,
|
|
164
|
+
within_days: int = 5,
|
|
165
|
+
minimum_attendance_percent: int = 100,
|
|
166
|
+
) -> List[Dict[str, Any]]:
|
|
167
|
+
"""Ask Graph for slots that suit everyone. Returns the suggestions, best first."""
|
|
168
|
+
now = datetime.now(timezone.utc)
|
|
169
|
+
suggestions = await self._collection_action("findMeetingTimes", {
|
|
170
|
+
"attendees": _attendees(attendees),
|
|
171
|
+
"timeConstraint": {
|
|
172
|
+
"activityDomain": "work",
|
|
173
|
+
"timeSlots": [{
|
|
174
|
+
"start": _graph_time(now, "UTC"),
|
|
175
|
+
"end": _graph_time(now + timedelta(days=within_days), "UTC"),
|
|
176
|
+
}],
|
|
177
|
+
},
|
|
178
|
+
"meetingDuration": f"PT{duration_minutes}M",
|
|
179
|
+
"minimumAttendeePercentage": minimum_attendance_percent,
|
|
180
|
+
"returnSuggestionReasons": True,
|
|
181
|
+
})
|
|
182
|
+
return (suggestions or {}).get("meetingTimeSuggestions", [])
|
|
183
|
+
|
|
184
|
+
async def free_busy(
|
|
185
|
+
self, people: Iterable[str], start: datetime, end: datetime, interval_minutes: int = 30
|
|
186
|
+
) -> List[Dict[str, Any]]:
|
|
187
|
+
"""Each person's availability over a window."""
|
|
188
|
+
schedules = await self._collection_action("calendar/getSchedule", {
|
|
189
|
+
"schedules": list(people),
|
|
190
|
+
"startTime": _graph_time(start, "UTC"),
|
|
191
|
+
"endTime": _graph_time(end, "UTC"),
|
|
192
|
+
"availabilityViewInterval": interval_minutes,
|
|
193
|
+
})
|
|
194
|
+
return (schedules or {}).get("value", [])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""OneDrive and SharePoint files.
|
|
2
|
+
|
|
3
|
+
The thing that makes Graph's file API hard to write against is addressing. The same item is
|
|
4
|
+
``/me/drive/items/01ABC...`` by id and ``/me/drive/root:/reports/q3.xlsx:`` by path -- with a colon
|
|
5
|
+
opening the path segment and *another* colon closing it before whatever comes next. Forgetting the
|
|
6
|
+
trailing colon gives a 400 that says nothing about colons.
|
|
7
|
+
|
|
8
|
+
``_address`` decides between the two on one rule: a leading slash means a path, anything else is an
|
|
9
|
+
id. Every method here takes either.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, AsyncIterator, Dict, Optional, 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__ = ["Files"]
|
|
23
|
+
|
|
24
|
+
DRIVE = "/me/drive"
|
|
25
|
+
|
|
26
|
+
DEFAULT_FIELDS = "id,name,size,webUrl,lastModifiedDateTime,file,folder"
|
|
27
|
+
|
|
28
|
+
#: Graph's own names for what happens when a new folder's name is taken.
|
|
29
|
+
_CONFLICT = ("rename", "replace", "fail")
|
|
30
|
+
|
|
31
|
+
#: Who a sharing link works for. "anonymous" is frequently disabled by tenant policy.
|
|
32
|
+
_SCOPES = ("anonymous", "organization", "users")
|
|
33
|
+
|
|
34
|
+
_LINK_KINDS = ("view", "edit", "embed")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Files(GraphResource):
|
|
38
|
+
"""Items in the signed-in user's drive.
|
|
39
|
+
|
|
40
|
+
Every method takes either a drive path (``"/reports/q3.xlsx"``, leading slash) or an item id.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
path = f"{DRIVE}/items"
|
|
44
|
+
scopes = Scopes.FILES_READ_WRITE
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _address(item: str) -> str:
|
|
48
|
+
"""The endpoint for one item, by path or by id."""
|
|
49
|
+
if not item or not item.strip():
|
|
50
|
+
raise GraphError(0, "invalidRequest", "'item' is required")
|
|
51
|
+
if not item.startswith("/"):
|
|
52
|
+
return f"{DRIVE}/items/{segment(item)}"
|
|
53
|
+
if item == "/":
|
|
54
|
+
return f"{DRIVE}/root"
|
|
55
|
+
# The closing colon is what separates the path from whatever follows it.
|
|
56
|
+
return f"{DRIVE}/root:{segment(item.rstrip('/'))}:"
|
|
57
|
+
|
|
58
|
+
# ── moving bytes ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async def upload(self, source: Union[str, Path], to: Optional[str] = None) -> Dict[str, Any]:
|
|
61
|
+
"""Send a local file to the drive, replacing whatever is at the destination.
|
|
62
|
+
|
|
63
|
+
``to`` is the destination drive path; omit it and the file keeps its own name at the root.
|
|
64
|
+
Files above 4 MiB switch to a resumable upload session on their own -- the caller never
|
|
65
|
+
picks a strategy, and the path is the same either way.
|
|
66
|
+
"""
|
|
67
|
+
local = Path(source)
|
|
68
|
+
if not local.is_file():
|
|
69
|
+
raise GraphError(0, "invalidRequest", f"'{source}' does not exist")
|
|
70
|
+
|
|
71
|
+
destination = to or f"/{local.name}"
|
|
72
|
+
if not destination.startswith("/"):
|
|
73
|
+
destination = "/" + destination
|
|
74
|
+
|
|
75
|
+
return await self._client.upload(f"{self._address(destination)}/content", str(local))
|
|
76
|
+
|
|
77
|
+
async def download(self, item: str, dest_path: Union[str, Path]) -> Dict[str, Any]:
|
|
78
|
+
"""Stream a file to disk. The destination directory must already exist.
|
|
79
|
+
|
|
80
|
+
Graph answers the content endpoint with a redirect to a short-lived, pre-authenticated URL
|
|
81
|
+
on another host. The transport follows it and deliberately does not attach the bearer token
|
|
82
|
+
to that second hop.
|
|
83
|
+
"""
|
|
84
|
+
return await self._client.download(f"{self._address(item)}/content", str(dest_path))
|
|
85
|
+
|
|
86
|
+
# ── looking around ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def folder(
|
|
89
|
+
self, path: str = "/", select: str = DEFAULT_FIELDS, top: int = 200
|
|
90
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
91
|
+
"""Everything directly inside a folder. Defaults to the drive root."""
|
|
92
|
+
return self._client.paged(f"{self._address(path)}/children", select=select, top=top)
|
|
93
|
+
|
|
94
|
+
def search(self, query: str, select: str = DEFAULT_FIELDS) -> AsyncIterator[Dict[str, Any]]:
|
|
95
|
+
"""Search the whole drive by name and content."""
|
|
96
|
+
if not query:
|
|
97
|
+
raise GraphError(0, "invalidRequest", "'query' is required")
|
|
98
|
+
# Graph spells this one as a function on the path, not as $search.
|
|
99
|
+
escaped = segment(query.replace("'", "''")).replace("/", "%2F")
|
|
100
|
+
return self._client.paged(f"{DRIVE}/root/search(q='{escaped}')", select=select)
|
|
101
|
+
|
|
102
|
+
async def metadata(self, item: str, select: str = DEFAULT_FIELDS) -> Dict[str, Any]:
|
|
103
|
+
"""One item's properties, without its bytes."""
|
|
104
|
+
return await self._client.get(self._address(item), select=select)
|
|
105
|
+
|
|
106
|
+
# ── changing things ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async def make_folder(self, path: str, conflict: str = "fail") -> Dict[str, Any]:
|
|
109
|
+
"""Create a folder. ``path`` is the full drive path of the folder to create."""
|
|
110
|
+
if not path.startswith("/"):
|
|
111
|
+
path = "/" + path
|
|
112
|
+
parent, _, name = path.rstrip("/").rpartition("/")
|
|
113
|
+
if not name:
|
|
114
|
+
raise GraphError(0, "invalidRequest", "'path' must name the folder to create")
|
|
115
|
+
|
|
116
|
+
if conflict not in _CONFLICT:
|
|
117
|
+
raise GraphError(
|
|
118
|
+
0, "invalidRequest", f"'conflict' must be one of {', '.join(_CONFLICT)}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return await self._client.post(f"{self._address(parent or '/')}/children", body={
|
|
122
|
+
"name": name,
|
|
123
|
+
"folder": {},
|
|
124
|
+
"@microsoft.graph.conflictBehavior": conflict,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
async def remove(self, item: str) -> None:
|
|
128
|
+
"""Move an item to the recycle bin."""
|
|
129
|
+
await self._client.delete(self._address(item))
|
|
130
|
+
|
|
131
|
+
async def share_link(
|
|
132
|
+
self, item: str, kind: str = "view", scope: str = "organization"
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Create a sharing link and return just the URL.
|
|
135
|
+
|
|
136
|
+
``scope="anonymous"`` produces a link anyone can open, and is frequently disabled by tenant
|
|
137
|
+
policy -- that arrives as an ``accessDenied`` error rather than a working link.
|
|
138
|
+
"""
|
|
139
|
+
if kind not in _LINK_KINDS:
|
|
140
|
+
raise GraphError(
|
|
141
|
+
0, "invalidRequest", f"'kind' must be one of {', '.join(_LINK_KINDS)}"
|
|
142
|
+
)
|
|
143
|
+
if scope not in _SCOPES:
|
|
144
|
+
raise GraphError(0, "invalidRequest", f"'scope' must be one of {', '.join(_SCOPES)}")
|
|
145
|
+
|
|
146
|
+
created = await self._client.post(
|
|
147
|
+
f"{self._address(item)}/createLink", body={"type": kind, "scope": scope}
|
|
148
|
+
)
|
|
149
|
+
return ((created or {}).get("link") or {}).get("webUrl", "")
|