mgraphctl 0.1.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.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mgraphctl/graph/files.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Shared drive-item operations for OneDrive and SharePoint (spec §8.11, §8.12).
|
|
2
|
+
|
|
3
|
+
Pure: client + params in, Graph dicts / PageResult / Plan out. `base` is one of `/me/drive`,
|
|
4
|
+
`/drives/{id}` or `/sites/{id}/drive`, already encoded by the caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from math import ceil
|
|
11
|
+
from mimetypes import guess_type
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mgraphctl import config, odata, resolve
|
|
16
|
+
from mgraphctl.errors import UsageError
|
|
17
|
+
from mgraphctl.http import (
|
|
18
|
+
JSON_HEADERS,
|
|
19
|
+
DownloadResult,
|
|
20
|
+
GraphClient,
|
|
21
|
+
PageResult,
|
|
22
|
+
Plan,
|
|
23
|
+
PlannedRequest,
|
|
24
|
+
)
|
|
25
|
+
from mgraphctl.render import Column, fmt_dt, fmt_size, to_iso_offset
|
|
26
|
+
|
|
27
|
+
ITEM_SELECT = "id,name,size,lastModifiedDateTime,file,folder,webUrl,parentReference"
|
|
28
|
+
PAGE_LS, CAP_LS = 200, 1000
|
|
29
|
+
CONFLICT_BEHAVIORS = frozenset({"rename", "replace", "fail"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def item_ref(base: str, ref: str) -> str:
|
|
33
|
+
"""`{base}/items/{id}` for an id (or `id:`-forced value), else `{base}/root:/{path}`."""
|
|
34
|
+
value, forced = resolve.split_id_prefix(ref)
|
|
35
|
+
if resolve.looks_like_id(ref, "drive_item"):
|
|
36
|
+
return f"{base}/items" + odata.p(value)
|
|
37
|
+
return f"{base}/root:/{odata.drive_path(value.lstrip('/'))}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def children_path(base: str, path: str | None) -> str:
|
|
41
|
+
if not path:
|
|
42
|
+
return f"{base}/root/children"
|
|
43
|
+
return f"{base}/root:/{odata.drive_path(path.lstrip('/'))}:/children"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_children(
|
|
47
|
+
client: GraphClient, base: str, path: str | None, *, limit: int | None, all_: bool
|
|
48
|
+
) -> PageResult:
|
|
49
|
+
return client.paginate(
|
|
50
|
+
children_path(base, path),
|
|
51
|
+
params={"$select": ITEM_SELECT, "$orderby": "name"},
|
|
52
|
+
limit=limit,
|
|
53
|
+
all_=all_,
|
|
54
|
+
cap=CAP_LS,
|
|
55
|
+
page_size=PAGE_LS,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def search_items(
|
|
60
|
+
client: GraphClient, base: str, q: str, *, limit: int | None, shared: bool = False
|
|
61
|
+
) -> PageResult:
|
|
62
|
+
prefix = base if shared else f"{base}/root"
|
|
63
|
+
path = f"{prefix}/search(q='{odata.odata_str(q)}')"
|
|
64
|
+
return client.paginate(
|
|
65
|
+
path,
|
|
66
|
+
params={"$select": ITEM_SELECT},
|
|
67
|
+
limit=limit,
|
|
68
|
+
all_=False,
|
|
69
|
+
cap=CAP_LS,
|
|
70
|
+
page_size=PAGE_LS,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_item(client: GraphClient, base: str, ref: str) -> dict:
|
|
75
|
+
return client.get(item_ref(base, ref), params={"$select": ITEM_SELECT})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_item_id(client: GraphClient, base: str, ref: str) -> str:
|
|
79
|
+
value, forced = resolve.split_id_prefix(ref)
|
|
80
|
+
if resolve.looks_like_id(ref, "drive_item"):
|
|
81
|
+
return value
|
|
82
|
+
return get_item(client, base, ref)["id"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _action_path(base: str, ref: str, action: str) -> str:
|
|
86
|
+
"""`item_ref(...)` plus an action segment, colon-fenced correctly for each ref form.
|
|
87
|
+
|
|
88
|
+
An id-form ref (`{base}/items/{id}`) takes the action as a plain path segment
|
|
89
|
+
(`/items/{id}/action`); a path-form ref (`{base}/root:/{path}`) needs the path's closing
|
|
90
|
+
colon before the action segment (`root:/{path}:/action`) or Graph 400s.
|
|
91
|
+
"""
|
|
92
|
+
sep = "/" if resolve.looks_like_id(ref, "drive_item") else ":/"
|
|
93
|
+
return item_ref(base, ref) + sep + action
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_name(item: dict) -> Path:
|
|
97
|
+
"""Where a download lands with no `--output`: the item's basename, never a path Graph chose."""
|
|
98
|
+
return Path(Path(item.get("name") or "").name or item["id"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def download_item(
|
|
102
|
+
client: GraphClient, base: str, ref: str, dest: Path | None
|
|
103
|
+
) -> tuple[dict, DownloadResult]:
|
|
104
|
+
item = get_item(client, base, ref)
|
|
105
|
+
result = client.download(_action_path(base, ref, "content"), dest or _default_name(item))
|
|
106
|
+
return item, result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dest(file: Path, dest: str | None) -> str:
|
|
110
|
+
"""`--dest` resolution (spec §8.11 `upload`): default `/<basename>`; trailing `/` = folder."""
|
|
111
|
+
if dest is None:
|
|
112
|
+
return f"/{file.name}"
|
|
113
|
+
if dest.endswith("/"):
|
|
114
|
+
return f"{dest}{file.name}"
|
|
115
|
+
return dest
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def plan_upload(
|
|
119
|
+
client: GraphClient, base: str, file: Path, dest: str | None, conflict: str
|
|
120
|
+
) -> Plan:
|
|
121
|
+
if conflict not in CONFLICT_BEHAVIORS:
|
|
122
|
+
raise UsageError(
|
|
123
|
+
"USAGE",
|
|
124
|
+
f"--conflict must be one of {', '.join(sorted(CONFLICT_BEHAVIORS))} (got {conflict!r})",
|
|
125
|
+
)
|
|
126
|
+
dest_path = _dest(file, dest)
|
|
127
|
+
encoded = odata.drive_path(dest_path.lstrip("/"))
|
|
128
|
+
size = file.stat().st_size
|
|
129
|
+
if size < config.DRIVE_SIMPLE_UPLOAD:
|
|
130
|
+
url = client.url(
|
|
131
|
+
f"{base}/root:/{encoded}:/content?@microsoft.graph.conflictBehavior={conflict}"
|
|
132
|
+
)
|
|
133
|
+
content_type = guess_type(file.name)[0] or "application/octet-stream"
|
|
134
|
+
return [PlannedRequest("PUT", url, {"Content-Type": content_type}, None, file=file)]
|
|
135
|
+
url = client.url(f"{base}/root:/{encoded}:/createUploadSession")
|
|
136
|
+
name = dest_path.rsplit("/", 1)[-1]
|
|
137
|
+
body = {"item": {"@microsoft.graph.conflictBehavior": conflict, "name": name}}
|
|
138
|
+
note = f"upload {size} bytes in {ceil(size / config.CHUNK_DRIVE)} chunks"
|
|
139
|
+
return [
|
|
140
|
+
PlannedRequest(
|
|
141
|
+
"POST",
|
|
142
|
+
url,
|
|
143
|
+
dict(JSON_HEADERS),
|
|
144
|
+
body,
|
|
145
|
+
file=file,
|
|
146
|
+
chunk_size=config.CHUNK_DRIVE,
|
|
147
|
+
note=note,
|
|
148
|
+
)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run(client: GraphClient, plan: Plan) -> Any:
|
|
153
|
+
"""Execute every step of `plan` in order, returning the last step's result."""
|
|
154
|
+
result: Any = None
|
|
155
|
+
for step in plan:
|
|
156
|
+
result = client.execute(step)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_upload(client: GraphClient, base: str, file: Path, dest: str | None, conflict: str) -> dict:
|
|
161
|
+
return _run(client, plan_upload(client, base, file, dest, conflict))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def plan_mkdir(client: GraphClient, base: str, path: str) -> Plan:
|
|
165
|
+
clean = path.strip("/")
|
|
166
|
+
if "/" in clean:
|
|
167
|
+
parent, name = clean.rsplit("/", 1)
|
|
168
|
+
url = client.url(f"{base}/root:/{odata.drive_path(parent)}:/children")
|
|
169
|
+
else:
|
|
170
|
+
name = clean
|
|
171
|
+
url = client.url(f"{base}/root/children")
|
|
172
|
+
body = {"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "fail"}
|
|
173
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_mkdir(client: GraphClient, base: str, path: str) -> dict:
|
|
177
|
+
return _run(client, plan_mkdir(client, base, path))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_folder_id(client: GraphClient, base: str, to: str) -> str:
|
|
181
|
+
value, forced = resolve.split_id_prefix(to)
|
|
182
|
+
if forced:
|
|
183
|
+
return value
|
|
184
|
+
return get_item(client, base, to)["id"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def plan_move(client: GraphClient, base: str, ref: str, to: str, name: str | None) -> Plan:
|
|
188
|
+
folder_id = _resolve_folder_id(client, base, to)
|
|
189
|
+
body: dict[str, Any] = {"parentReference": {"id": folder_id}}
|
|
190
|
+
if name is not None:
|
|
191
|
+
body["name"] = name
|
|
192
|
+
url = client.url(item_ref(base, ref))
|
|
193
|
+
return [PlannedRequest("PATCH", url, dict(JSON_HEADERS), body)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_move(client: GraphClient, base: str, ref: str, to: str, name: str | None) -> dict:
|
|
197
|
+
return _run(client, plan_move(client, base, ref, to, name))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def plan_rename(client: GraphClient, base: str, ref: str, name: str) -> Plan:
|
|
201
|
+
url = client.url(item_ref(base, ref))
|
|
202
|
+
return [PlannedRequest("PATCH", url, dict(JSON_HEADERS), {"name": name})]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_rename(client: GraphClient, base: str, ref: str, name: str) -> dict:
|
|
206
|
+
return _run(client, plan_rename(client, base, ref, name))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def plan_delete(client: GraphClient, base: str, ref: str) -> Plan:
|
|
210
|
+
url = client.url(item_ref(base, ref))
|
|
211
|
+
return [PlannedRequest("DELETE", url, {}, None, expect="none")]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_delete(client: GraphClient, base: str, ref: str) -> dict | None:
|
|
215
|
+
return _run(client, plan_delete(client, base, ref))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def plan_share(
|
|
219
|
+
client: GraphClient,
|
|
220
|
+
base: str,
|
|
221
|
+
ref: str,
|
|
222
|
+
*,
|
|
223
|
+
link_type: str,
|
|
224
|
+
scope: str,
|
|
225
|
+
expires: datetime | None,
|
|
226
|
+
) -> Plan:
|
|
227
|
+
body: dict[str, Any] = {"type": link_type, "scope": scope}
|
|
228
|
+
if expires is not None:
|
|
229
|
+
body["expirationDateTime"] = to_iso_offset(expires)
|
|
230
|
+
url = client.url(_action_path(base, ref, "createLink"))
|
|
231
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def run_share(
|
|
235
|
+
client: GraphClient,
|
|
236
|
+
base: str,
|
|
237
|
+
ref: str,
|
|
238
|
+
*,
|
|
239
|
+
link_type: str,
|
|
240
|
+
scope: str,
|
|
241
|
+
expires: datetime | None,
|
|
242
|
+
) -> dict:
|
|
243
|
+
return _run(
|
|
244
|
+
client, plan_share(client, base, ref, link_type=link_type, scope=scope, expires=expires)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_shared_item(client: GraphClient, url: str) -> dict:
|
|
249
|
+
return client.get(f"/shares/{odata.share_id(url)}/driveItem")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def download_shared_item(
|
|
253
|
+
client: GraphClient, url: str, dest: Path | None
|
|
254
|
+
) -> tuple[dict, DownloadResult]:
|
|
255
|
+
item = get_shared_item(client, url)
|
|
256
|
+
result = client.download(
|
|
257
|
+
f"/shares/{odata.share_id(url)}/driveItem/content", dest or _default_name(item)
|
|
258
|
+
)
|
|
259
|
+
return item, result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def item_columns(tz: str) -> list[Column]:
|
|
263
|
+
return [
|
|
264
|
+
Column("type", lambda i: "d" if "folder" in i else "f"),
|
|
265
|
+
Column("id", "id"),
|
|
266
|
+
Column("size", lambda i: fmt_size(i.get("size"))),
|
|
267
|
+
Column("modified", lambda i: fmt_dt(i.get("lastModifiedDateTime"), tz)),
|
|
268
|
+
Column("name", "name"),
|
|
269
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Graph operations for Microsoft 365 groups (spec §8.16).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / PageResult out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from mgraphctl import odata
|
|
9
|
+
from mgraphctl.graph.users import GROUPS_PATH, list_unified_groups
|
|
10
|
+
from mgraphctl.http import GraphClient, PageResult
|
|
11
|
+
from mgraphctl.resolve import looks_like_id, pick_unique
|
|
12
|
+
|
|
13
|
+
LIST_SELECT = "id,displayName,mail,groupTypes,description"
|
|
14
|
+
MEMBERS_SELECT = "id,displayName,userPrincipalName,mail,jobTitle"
|
|
15
|
+
|
|
16
|
+
PAGE = 100
|
|
17
|
+
CAP = 999
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_groups(client: GraphClient, *, unified: bool, limit: int, all_: bool) -> PageResult:
|
|
21
|
+
"""Groups the signed-in user belongs to; `--unified` narrows to Microsoft 365 groups."""
|
|
22
|
+
if unified:
|
|
23
|
+
items = list_unified_groups(client)
|
|
24
|
+
bound = CAP if all_ else min(limit, CAP)
|
|
25
|
+
return PageResult(items=items[:bound], truncated=len(items) > bound, pages=1)
|
|
26
|
+
return client.paginate(
|
|
27
|
+
GROUPS_PATH,
|
|
28
|
+
params={"$select": LIST_SELECT},
|
|
29
|
+
limit=limit,
|
|
30
|
+
all_=all_,
|
|
31
|
+
cap=CAP,
|
|
32
|
+
page_size=PAGE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_members(client: GraphClient, group_id: str, *, limit: int, all_: bool) -> PageResult:
|
|
37
|
+
path = f"{odata.p('groups', group_id)}/members"
|
|
38
|
+
return client.paginate(
|
|
39
|
+
path, params={"$select": MEMBERS_SELECT}, limit=limit, all_=all_, cap=CAP, page_size=PAGE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_group(client: GraphClient, value: str) -> dict:
|
|
44
|
+
"""Turn a group name or id into a group object (at least `id`); by-name via `memberOf`."""
|
|
45
|
+
if looks_like_id(value, "group"):
|
|
46
|
+
return {"id": value}
|
|
47
|
+
page = client.paginate(
|
|
48
|
+
GROUPS_PATH,
|
|
49
|
+
params={"$select": LIST_SELECT},
|
|
50
|
+
limit=None,
|
|
51
|
+
all_=True,
|
|
52
|
+
cap=CAP,
|
|
53
|
+
page_size=PAGE,
|
|
54
|
+
)
|
|
55
|
+
return pick_unique(page.items, "displayName", value, what="group")
|