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.

Files changed (57) hide show
  1. mgraphctl/__init__.py +3 -0
  2. mgraphctl/__main__.py +3 -0
  3. mgraphctl/auth.py +395 -0
  4. mgraphctl/cli.py +259 -0
  5. mgraphctl/commands/__init__.py +71 -0
  6. mgraphctl/commands/api.py +164 -0
  7. mgraphctl/commands/calendar.py +491 -0
  8. mgraphctl/commands/chats.py +329 -0
  9. mgraphctl/commands/config_cmd.py +140 -0
  10. mgraphctl/commands/groups.py +65 -0
  11. mgraphctl/commands/mail.py +705 -0
  12. mgraphctl/commands/mailbox.py +176 -0
  13. mgraphctl/commands/meetings.py +294 -0
  14. mgraphctl/commands/onedrive.py +351 -0
  15. mgraphctl/commands/onenote.py +196 -0
  16. mgraphctl/commands/org.py +102 -0
  17. mgraphctl/commands/people.py +201 -0
  18. mgraphctl/commands/planner.py +268 -0
  19. mgraphctl/commands/presence.py +102 -0
  20. mgraphctl/commands/search.py +75 -0
  21. mgraphctl/commands/sharepoint.py +304 -0
  22. mgraphctl/commands/teams.py +235 -0
  23. mgraphctl/commands/todo.py +253 -0
  24. mgraphctl/commands/top.py +284 -0
  25. mgraphctl/config.py +385 -0
  26. mgraphctl/errors.py +276 -0
  27. mgraphctl/fixtures.py +149 -0
  28. mgraphctl/graph/__init__.py +1 -0
  29. mgraphctl/graph/calendar.py +313 -0
  30. mgraphctl/graph/chats.py +447 -0
  31. mgraphctl/graph/files.py +269 -0
  32. mgraphctl/graph/groups.py +55 -0
  33. mgraphctl/graph/mail.py +663 -0
  34. mgraphctl/graph/mailbox.py +89 -0
  35. mgraphctl/graph/meetings.py +269 -0
  36. mgraphctl/graph/onedrive.py +35 -0
  37. mgraphctl/graph/onenote.py +138 -0
  38. mgraphctl/graph/org.py +86 -0
  39. mgraphctl/graph/people.py +83 -0
  40. mgraphctl/graph/planner.py +415 -0
  41. mgraphctl/graph/presence.py +89 -0
  42. mgraphctl/graph/search.py +178 -0
  43. mgraphctl/graph/sharepoint.py +294 -0
  44. mgraphctl/graph/teams.py +240 -0
  45. mgraphctl/graph/todo.py +199 -0
  46. mgraphctl/graph/users.py +85 -0
  47. mgraphctl/html.py +127 -0
  48. mgraphctl/http.py +843 -0
  49. mgraphctl/odata.py +72 -0
  50. mgraphctl/render.py +627 -0
  51. mgraphctl/resolve.py +111 -0
  52. mgraphctl/token_store.py +196 -0
  53. mgraphctl-0.1.0.dist-info/METADATA +226 -0
  54. mgraphctl-0.1.0.dist-info/RECORD +57 -0
  55. mgraphctl-0.1.0.dist-info/WHEEL +4 -0
  56. mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
  57. mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,89 @@
1
+ """Graph operations for mailbox settings, automatic replies and the focused inbox (spec §8.3).
2
+
3
+ Pure: client and parameters in, Graph dicts / `PageResult` / `Plan` out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+
10
+ from mgraphctl.graph import mail
11
+ from mgraphctl.html import text_to_html
12
+ from mgraphctl.http import JSON_HEADERS, GraphClient, PageResult, Plan, PlannedRequest
13
+ from mgraphctl.render import to_graph_dtz
14
+
15
+ SETTINGS_PATH = "/me/mailboxSettings"
16
+ OOF_PATH = "/me/mailboxSettings/automaticRepliesSetting"
17
+ FOCUSED_PATH = "/me/mailFolders/inbox/messages"
18
+
19
+ # `--external` values → the Graph `externalAudience` enum (spec §8.3 `oof set`).
20
+ AUDIENCE = {"all": "all", "contacts": "contactsOnly", "none": "none"}
21
+
22
+
23
+ def get_settings(client: GraphClient) -> dict:
24
+ return client.get(SETTINGS_PATH)
25
+
26
+
27
+ def get_oof(client: GraphClient) -> dict:
28
+ return client.get(OOF_PATH)
29
+
30
+
31
+ def plan_set_oof(
32
+ client: GraphClient,
33
+ *,
34
+ message: str | None,
35
+ external_message: str | None,
36
+ start: datetime | None,
37
+ end: datetime | None,
38
+ audience: str,
39
+ clear: bool,
40
+ tz: str,
41
+ ) -> Plan:
42
+ """`scheduled` when both ends are given, `alwaysEnabled` otherwise, `disabled` for --clear."""
43
+ if clear:
44
+ setting: dict[str, object] = {"status": "disabled"}
45
+ else:
46
+ setting = {
47
+ "status": "scheduled" if start and end else "alwaysEnabled",
48
+ "externalAudience": audience,
49
+ }
50
+ if start and end:
51
+ setting["scheduledStartDateTime"] = to_graph_dtz(start, tz)
52
+ setting["scheduledEndDateTime"] = to_graph_dtz(end, tz)
53
+ setting["internalReplyMessage"] = text_to_html(message or "")
54
+ setting["externalReplyMessage"] = text_to_html(external_message or message or "")
55
+ return [
56
+ PlannedRequest(
57
+ "PATCH",
58
+ client.url(SETTINGS_PATH),
59
+ dict(JSON_HEADERS),
60
+ {"automaticRepliesSetting": setting},
61
+ )
62
+ ]
63
+
64
+
65
+ def list_focused(
66
+ client: GraphClient, *, other: bool, after: datetime, limit: int | None, all_: bool
67
+ ) -> PageResult:
68
+ """Inbox messages Outlook classified as focused (or `--other`), newest first.
69
+
70
+ `receivedDateTime` leads the filter so Graph can serve the `$orderby` from the same index.
71
+ """
72
+ classification = "other" if other else "focused"
73
+ params = {
74
+ "$select": mail.LIST_SELECT,
75
+ "$filter": (
76
+ f"receivedDateTime ge {after.isoformat()} "
77
+ f"and inferenceClassification eq '{classification}'"
78
+ ),
79
+ "$orderby": "receivedDateTime desc",
80
+ }
81
+ return client.paginate(
82
+ FOCUSED_PATH,
83
+ params=params,
84
+ outlook_tz=True,
85
+ limit=limit,
86
+ all_=all_,
87
+ cap=mail.CAP_LIST,
88
+ page_size=mail.PAGE_FILTER,
89
+ )
@@ -0,0 +1,269 @@
1
+ """Graph operations for online meetings, transcripts and AI insights (spec §8.10).
2
+
3
+ Pure: client and parameters in, Graph dicts / lists out. `meetings list` resolves its own
4
+ calendar window (its own `$select`, no import of `graph/calendar` — that module is owned by
5
+ a different task and this one must not depend on it).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from mgraphctl import odata
14
+ from mgraphctl.errors import GraphError, MsgraphError, NotFoundError, UsageError
15
+ from mgraphctl.http import BatchRequest, DownloadResult, GraphClient, PageResult
16
+ from mgraphctl.render import to_iso_offset
17
+
18
+ EVENT_SELECT = "id,subject,start,end,organizer,isOnlineMeeting,onlineMeeting"
19
+ PAGE_SIZE = 50
20
+ CAP = 200
21
+
22
+ TRANSCRIPT_TEXT_ACCEPT = "application/vnd.microsoft.graph.transcript+text"
23
+
24
+ NO_LICENSE_NOTE = (
25
+ "AI insights require a Microsoft 365 Copilot license, or this meeting is not eligible."
26
+ )
27
+ NO_INSIGHTS_NOTE = "No AI insights are available for this meeting."
28
+
29
+
30
+ def list_online_events(
31
+ client: GraphClient,
32
+ *,
33
+ start: datetime,
34
+ end: datetime,
35
+ subject: str | None,
36
+ limit: int,
37
+ tz: str,
38
+ ) -> PageResult:
39
+ """Calendar events in `[start, end]` that are online meetings with a join URL.
40
+
41
+ `truncated` (and `pages`) are threaded through from the underlying `calendarView`
42
+ pagination unchanged: filtering to online meetings (and by `--subject`) only ever removes
43
+ items, so a page that was truncated before filtering still means "there may be more".
44
+ """
45
+ params = {
46
+ "startDateTime": to_iso_offset(start),
47
+ "endDateTime": to_iso_offset(end),
48
+ "$select": EVENT_SELECT,
49
+ }
50
+ page = client.paginate(
51
+ "/me/calendarView",
52
+ params=params,
53
+ outlook_tz=True,
54
+ limit=limit,
55
+ all_=False,
56
+ cap=CAP,
57
+ page_size=PAGE_SIZE,
58
+ )
59
+ events = [
60
+ e
61
+ for e in page.items
62
+ if e.get("isOnlineMeeting") and (e.get("onlineMeeting") or {}).get("joinUrl")
63
+ ]
64
+ if subject:
65
+ needle = subject.casefold()
66
+ events = [e for e in events if needle in (e.get("subject") or "").casefold()]
67
+ return PageResult(items=events, truncated=page.truncated, pages=page.pages)
68
+
69
+
70
+ def resolve_meetings(client: GraphClient, events: list[dict]) -> dict[str, dict]:
71
+ """`$batch` of one `onlineMeetings` join-URL filter per event; keyed by event id."""
72
+ targets = [e for e in events if (e.get("onlineMeeting") or {}).get("joinUrl")]
73
+ if not targets:
74
+ return {}
75
+ requests = [
76
+ BatchRequest(
77
+ id=event["id"],
78
+ method="GET",
79
+ url=odata.with_query(
80
+ "/me/onlineMeetings",
81
+ {"$filter": _join_url_filter(event["onlineMeeting"]["joinUrl"])},
82
+ ),
83
+ )
84
+ for event in targets
85
+ ]
86
+ resolved: dict[str, dict] = {}
87
+ for response in client.batch(requests):
88
+ if response.error is not None:
89
+ continue
90
+ values = (response.body or {}).get("value") or []
91
+ if values:
92
+ resolved[response.id] = values[0]
93
+ return resolved
94
+
95
+
96
+ def transcripts_for(client: GraphClient, meeting_ids: list[str]) -> dict[str, list]:
97
+ """`GET /me/onlineMeetings/{id}/transcripts` for each meeting id, one call per id."""
98
+ result: dict[str, list] = {}
99
+ for meeting_id in meeting_ids:
100
+ if meeting_id in result:
101
+ continue
102
+ page = client.get(odata.p("me", "onlineMeetings", meeting_id, "transcripts"))
103
+ result[meeting_id] = (page or {}).get("value") or []
104
+ return result
105
+
106
+
107
+ def _join_url_filter(join_url: str) -> str:
108
+ return f"JoinWebUrl eq '{odata.odata_str(join_url)}'"
109
+
110
+
111
+ def _by_join_url(client: GraphClient, join_url: str) -> dict:
112
+ result = client.get("/me/onlineMeetings", params={"$filter": _join_url_filter(join_url)})
113
+ values = (result or {}).get("value") or []
114
+ if not values:
115
+ raise NotFoundError("NOT_FOUND", f"no online meeting found for join URL {join_url!r}")
116
+ return values[0]
117
+
118
+
119
+ def select_meeting(
120
+ client: GraphClient, meeting: str | None, join_url: str | None, event: str | None
121
+ ) -> dict:
122
+ """Resolve exactly one of MEETING / `--join-url` / `--event` to a meeting dict (spec §6.6).
123
+
124
+ A bare `MEETING` id costs no request (`{"id": meeting}`); `--join-url` and `--event` both
125
+ end in the `$filter` lookup, which already returns the full record.
126
+ """
127
+ given = [v for v in (meeting, join_url, event) if v is not None]
128
+ if len(given) != 1:
129
+ raise UsageError("USAGE", "give exactly one of MEETING, --join-url, or --event")
130
+ if meeting is not None:
131
+ return {"id": meeting}
132
+ if event is not None:
133
+ ev = client.get(odata.p("me", "events", event), params={"$select": "onlineMeeting"})
134
+ join_url = (ev.get("onlineMeeting") or {}).get("joinUrl")
135
+ if not join_url:
136
+ raise NotFoundError("NOT_FOUND", f"event {event!r} has no online meeting")
137
+ return _by_join_url(client, join_url)
138
+
139
+
140
+ def get_meeting(
141
+ client: GraphClient,
142
+ meeting: str | None = None,
143
+ *,
144
+ join_url: str | None = None,
145
+ event: str | None = None,
146
+ ) -> dict:
147
+ """The full online meeting record, however it was selected (`meetings get`, spec §8.10).
148
+
149
+ `select_meeting` costs no request for a bare id (it hands back `{"id": meeting}` so the
150
+ cheaper verbs never over-fetch); `get` always wants the full record, so a bare id gets one
151
+ extra `GET /me/onlineMeetings/{id}` here. `--join-url` and `--event` already resolve
152
+ through the `$filter` lookup, which returns the full record, so no second call is needed.
153
+ """
154
+ selected = select_meeting(client, meeting, join_url, event)
155
+ if meeting is not None:
156
+ return client.get(odata.p("me", "onlineMeetings", selected["id"]))
157
+ return selected
158
+
159
+
160
+ def list_transcripts(client: GraphClient, meeting_id: str) -> list[dict]:
161
+ result = client.get(odata.p("me", "onlineMeetings", meeting_id, "transcripts"))
162
+ return (result or {}).get("value") or []
163
+
164
+
165
+ def looks_like_vtt(body: str) -> bool:
166
+ """Whether a transcript body is WebVTT, which its required signature line says (§8.11).
167
+
168
+ What Graph returns does not always follow what was asked for: `$format=text/vtt` can come
169
+ back as plain text on the speaker-attribution fallback, and some meeting types answer with
170
+ vtt regardless. The body itself is the only honest source.
171
+ """
172
+ return body.lstrip("\ufeff").lstrip().startswith("WEBVTT")
173
+
174
+
175
+ def get_transcript_body(client: GraphClient, meeting_id: str, transcript_id: str) -> str:
176
+ """The transcript exactly as Graph returned it — WebVTT, or plain text on the fallback.
177
+
178
+ A 403 `SpeakerAttributionNotAllowed` is retried once with an `Accept` header that asks
179
+ Graph for the plain-text representation directly (already speaker-scrubbed). That fallback
180
+ has no WebVTT form, so it always yields plain text; the caller sniffs the body with
181
+ `looks_like_vtt` rather than assuming it got what it asked for.
182
+ """
183
+ path = odata.p("me", "onlineMeetings", meeting_id, "transcripts", transcript_id, "content")
184
+ try:
185
+ return client.request("GET", path, params={"$format": "text/vtt"}, expect="text")
186
+ except GraphError as exc:
187
+ if exc.status == 403 and exc.code == "SpeakerAttributionNotAllowed":
188
+ return client.request(
189
+ "GET", path, headers={"Accept": TRANSCRIPT_TEXT_ACCEPT}, expect="text"
190
+ )
191
+ raise
192
+
193
+
194
+ def transcript_created_at(client: GraphClient, meeting_id: str, transcript_id: str) -> str | None:
195
+ """Best-effort `createdDateTime` for one transcript, or None when the lookup fails.
196
+
197
+ Costs one extra list request, so only the `--json` path — the only one that emits the
198
+ field — pays it. The content fetch has already succeeded by this point, so a failure here
199
+ is never fatal.
200
+ """
201
+ try:
202
+ listed = list_transcripts(client, meeting_id)
203
+ except (MsgraphError, ValueError): # ValueError covers a 200 carrying unparseable JSON
204
+ return None
205
+ for item in listed:
206
+ if item.get("id") == transcript_id:
207
+ return item.get("createdDateTime")
208
+ return None
209
+
210
+
211
+ def insights(client: GraphClient, oid: str, meeting_id: str) -> tuple[list[dict], str | None]:
212
+ """AI insights: v1.0 first, `/beta` on 404; per-item detail from whichever base answered.
213
+
214
+ A 403 at either stage becomes a soft "needs a Copilot license" note, not an error; a
215
+ per-item detail failure keeps the summary list entry instead of dropping it. The summary
216
+ call passes no explicit `beta=`, so it honours the client's global `--beta` flag like any
217
+ other call; `beta` here tracks whichever base actually answered (the client's own setting
218
+ when the first call succeeded, or `True` when the 404 fallback to `/beta` was needed) so
219
+ the per-item detail GETs land on the same base as the summary that named them.
220
+ """
221
+ path = odata.p("copilot", "users", oid, "onlineMeetings", meeting_id, "aiInsights")
222
+ try:
223
+ summary = client.get(path)
224
+ beta = client.beta
225
+ except GraphError as exc:
226
+ if exc.status == 403:
227
+ return [], NO_LICENSE_NOTE
228
+ if exc.status != 404:
229
+ raise
230
+ try:
231
+ summary = client.get(path, beta=True)
232
+ beta = True
233
+ except GraphError as exc2:
234
+ if exc2.status == 403:
235
+ return [], NO_LICENSE_NOTE
236
+ if exc2.status == 404:
237
+ return [], NO_INSIGHTS_NOTE
238
+ raise
239
+ items = (summary or {}).get("value") or []
240
+ if not items:
241
+ return [], NO_INSIGHTS_NOTE
242
+ detailed: list[dict] = []
243
+ for item in items:
244
+ detail_path = odata.p(
245
+ "copilot",
246
+ "users",
247
+ oid,
248
+ "onlineMeetings",
249
+ meeting_id,
250
+ "aiInsights",
251
+ str(item.get("id")),
252
+ )
253
+ try:
254
+ detailed.append(client.get(detail_path, beta=beta))
255
+ except GraphError:
256
+ detailed.append(item)
257
+ return detailed, None
258
+
259
+
260
+ def list_recordings(client: GraphClient, meeting_id: str) -> list[dict]:
261
+ result = client.get(odata.p("me", "onlineMeetings", meeting_id, "recordings"))
262
+ return (result or {}).get("value") or []
263
+
264
+
265
+ def download_recording(
266
+ client: GraphClient, meeting_id: str, recording_id: str, dest: Path
267
+ ) -> DownloadResult:
268
+ path = odata.p("me", "onlineMeetings", meeting_id, "recordings", recording_id, "content")
269
+ return client.download(path, dest)
@@ -0,0 +1,35 @@
1
+ """OneDrive-only Graph operations (spec §8.11): base resolution for `--drive`, plus the two
2
+ listings that are always relative to the signed-in user's own drive (`sharedWithMe`, `recent`).
3
+
4
+ Every other onedrive verb delegates straight to `mgraphctl.graph.files` with a `base` resolved
5
+ by `base_for()` here; that module already implements every drive-item operation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mgraphctl import odata
11
+ from mgraphctl.http import GraphClient, PageResult
12
+
13
+ CAP_SHARED_WITH_ME = 200
14
+ CAP_RECENT = 200
15
+
16
+
17
+ def base_for(drive_id: str | None) -> str:
18
+ """`/me/drive`, or `/drives/{id}` when `--drive` names another drive."""
19
+ if drive_id is None:
20
+ return "/me/drive"
21
+ return odata.p("drives", drive_id)
22
+
23
+
24
+ def shared_with_me(client: GraphClient, *, limit: int) -> PageResult:
25
+ """Items other people have shared (§8.11 `shared-with-me`); always the caller's own drive."""
26
+ return client.paginate(
27
+ "/me/drive/sharedWithMe", limit=limit, all_=False, cap=CAP_SHARED_WITH_ME, page_size=None
28
+ )
29
+
30
+
31
+ def recent(client: GraphClient, *, limit: int) -> PageResult:
32
+ """Recently used files (§8.11 `recent`); always the caller's own drive."""
33
+ return client.paginate(
34
+ "/me/drive/recent", limit=limit, all_=False, cap=CAP_RECENT, page_size=None
35
+ )
@@ -0,0 +1,138 @@
1
+ """Graph operations for OneNote notebooks, sections and pages (spec §8.13).
2
+
3
+ Pure: client and parameters in, Graph dicts / PageResult / Plan out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from html import escape as escape_html
9
+
10
+ from mgraphctl.errors import GraphError
11
+ from mgraphctl.html import text_to_html
12
+ from mgraphctl.http import GraphClient, PageResult, Plan, PlannedRequest
13
+ from mgraphctl.odata import p
14
+ from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
15
+
16
+ NOTEBOOK_SELECT = "id,displayName,lastModifiedDateTime,links"
17
+ SECTION_SELECT = "id,displayName,lastModifiedDateTime,parentNotebook"
18
+ PAGE_SELECT = "id,title,lastModifiedDateTime,links"
19
+ SEARCH_SELECT = "id,title,createdDateTime,parentSection"
20
+ HTML_CONTENT = {"Content-Type": "text/html"}
21
+
22
+ PAGE_TOP = 100
23
+ CAP_LIST = 500
24
+
25
+
26
+ def list_notebooks(client: GraphClient, *, limit: int, all_: bool = False) -> PageResult:
27
+ return client.paginate(
28
+ "/me/onenote/notebooks",
29
+ params={"$select": NOTEBOOK_SELECT},
30
+ limit=limit,
31
+ all_=all_,
32
+ cap=CAP_LIST,
33
+ page_size=PAGE_TOP,
34
+ )
35
+
36
+
37
+ def resolve_notebook(client: GraphClient, value: str) -> str:
38
+ """A notebook id, or the id of the notebook whose `displayName` matches `value` (§6.6)."""
39
+ bare, forced = split_id_prefix(value)
40
+ if looks_like_id(value, "onenote"):
41
+ return bare
42
+ payload = client.get("/me/onenote/notebooks", params={"$top": PAGE_TOP}) or {}
43
+ match = pick_unique(payload.get("value") or [], "displayName", value, what="notebook")
44
+ return match["id"]
45
+
46
+
47
+ def list_sections(
48
+ client: GraphClient, notebook_id: str | None, *, limit: int, all_: bool = False
49
+ ) -> PageResult:
50
+ path = (
51
+ "/me/onenote/sections"
52
+ if notebook_id is None
53
+ else p("me", "onenote", "notebooks", notebook_id, "sections")
54
+ )
55
+ return client.paginate(
56
+ path,
57
+ params={"$select": SECTION_SELECT},
58
+ limit=limit,
59
+ all_=all_,
60
+ cap=CAP_LIST,
61
+ page_size=PAGE_TOP,
62
+ )
63
+
64
+
65
+ def resolve_section(client: GraphClient, value: str) -> str:
66
+ """A section id, or the id of the section whose `displayName` matches `value` (§6.6)."""
67
+ bare, forced = split_id_prefix(value)
68
+ if looks_like_id(value, "onenote"):
69
+ return bare
70
+ payload = client.get("/me/onenote/sections", params={"$top": PAGE_TOP}) or {}
71
+ match = pick_unique(payload.get("value") or [], "displayName", value, what="section")
72
+ return match["id"]
73
+
74
+
75
+ def list_pages(client: GraphClient, section_id: str, *, limit: int, all_: bool) -> PageResult:
76
+ path = p("me", "onenote", "sections", section_id, "pages")
77
+ return client.paginate(
78
+ path,
79
+ params={"$select": PAGE_SELECT, "$orderby": "lastModifiedDateTime desc"},
80
+ limit=limit,
81
+ all_=all_,
82
+ cap=CAP_LIST,
83
+ page_size=PAGE_TOP,
84
+ )
85
+
86
+
87
+ def resolve_page(client: GraphClient, value: str) -> str:
88
+ """A page id, or the id of the page whose `title` matches `value` (§6.6)."""
89
+ bare, forced = split_id_prefix(value)
90
+ if looks_like_id(value, "onenote"):
91
+ return bare
92
+ payload = client.get("/me/onenote/pages", params={"$top": PAGE_TOP}) or {}
93
+ match = pick_unique(payload.get("value") or [], "title", value, what="page")
94
+ return match["id"]
95
+
96
+
97
+ def get_page(client: GraphClient, page_id: str) -> tuple[dict, str]:
98
+ """A page's metadata and its HTML content (two Graph calls; §8.13 `read`)."""
99
+ meta = client.get(p("me", "onenote", "pages", page_id), params={"$select": PAGE_SELECT})
100
+ content = client.get(
101
+ p("me", "onenote", "pages", page_id, "content"),
102
+ params={"includeIDs": True},
103
+ expect="text",
104
+ )
105
+ return meta, content
106
+
107
+
108
+ def plan_create_page(
109
+ client: GraphClient, section_id: str, *, title: str, body: str, html: bool
110
+ ) -> Plan:
111
+ """`POST .../pages` with an HTML string body.
112
+
113
+ Title is always escaped; body is escaped and paragraph-wrapped unless `html` (quirk 14).
114
+ """
115
+ content = body if html else text_to_html(body)
116
+ page_html = (
117
+ f"<!DOCTYPE html><html><head><title>{escape_html(title)}</title></head>"
118
+ f"<body>{content}</body></html>"
119
+ )
120
+ url = client.url(p("me", "onenote", "sections", section_id, "pages"))
121
+ return [PlannedRequest("POST", url, dict(HTML_CONTENT), page_html)]
122
+
123
+
124
+ def search_pages(client: GraphClient, q: str, *, limit: int) -> PageResult:
125
+ """`$search` over page titles/content; Graph only supports this for consumer notebooks."""
126
+ try:
127
+ return client.paginate(
128
+ "/me/onenote/pages",
129
+ params={"$search": q, "$select": SEARCH_SELECT},
130
+ limit=limit,
131
+ all_=False,
132
+ cap=CAP_LIST,
133
+ page_size=PAGE_TOP,
134
+ )
135
+ except GraphError as exc:
136
+ if exc.status in (400, 501):
137
+ exc.hint = f"search {q} --type driveItem"
138
+ raise
mgraphctl/graph/org.py ADDED
@@ -0,0 +1,86 @@
1
+ """Graph operations for manager, direct reports and the management chain (spec §8.6).
2
+
3
+ Pure: client and parameters in, Graph dicts / PageResult / list[dict] out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from mgraphctl import odata
9
+ from mgraphctl.errors import GraphError, NotFoundError
10
+ from mgraphctl.http import GraphClient, PageResult
11
+
12
+ MANAGER_SELECT = "id,displayName,userPrincipalName,mail,jobTitle,department"
13
+ CHAIN_SELECT = "id,displayName,userPrincipalName,jobTitle"
14
+ EVENTUAL = {"ConsistencyLevel": "eventual"}
15
+
16
+ PAGE = 100
17
+ CAP = 500
18
+
19
+
20
+ def _user_path(upn: str | None) -> str:
21
+ return "/me" if upn is None else odata.p("users", upn)
22
+
23
+
24
+ def manager(client: GraphClient, upn: str | None) -> dict:
25
+ """The user's manager (self by default). 404 becomes a clear NOT_FOUND (spec §8.6)."""
26
+ try:
27
+ return client.get(f"{_user_path(upn)}/manager", params={"$select": MANAGER_SELECT})
28
+ except GraphError as exc:
29
+ if exc.status != 404:
30
+ raise
31
+ raise NotFoundError("NOT_FOUND", "No manager found", request_id=exc.request_id) from exc
32
+
33
+
34
+ def reports(client: GraphClient, upn: str | None, *, limit: int, all_: bool) -> PageResult:
35
+ """The user's direct reports (self by default)."""
36
+ path = f"{_user_path(upn)}/directReports"
37
+ return client.paginate(
38
+ path, params={"$select": MANAGER_SELECT}, limit=limit, all_=all_, cap=CAP, page_size=PAGE
39
+ )
40
+
41
+
42
+ def _walk_expanded(obj: dict) -> list[dict]:
43
+ """Self, then each nested `manager`, flattened from the `$expand` response."""
44
+ levels = []
45
+ current: dict | None = obj
46
+ while current is not None:
47
+ levels.append({k: v for k, v in current.items() if k != "manager"})
48
+ current = current.get("manager")
49
+ return levels
50
+
51
+
52
+ def chain_expand(client: GraphClient, upn: str | None, *, max_levels: int) -> list[dict]:
53
+ """The management chain via one `$expand=manager($levels=max)` call (spec §8.6 `chain`).
54
+
55
+ Self, then each nested `manager`, from the single response. Raises `GraphError` (400/403
56
+ on a tenant that rejects the expand) for the caller to catch and fall back to
57
+ `chain_iterative`.
58
+ """
59
+ path = _user_path(upn)
60
+ expand = f"manager($levels=max;$select={CHAIN_SELECT})"
61
+ obj = client.get(path, params={"$expand": expand, "$count": True}, headers=EVENTUAL)
62
+ return _walk_expanded(obj)[: max_levels + 1]
63
+
64
+
65
+ def chain_iterative(client: GraphClient, upn: str | None, *, max_levels: int) -> list[dict]:
66
+ """The management chain via a `/users/{id}/manager` walk, one level at a time.
67
+
68
+ The fallback path for tenants that reject `$expand`; callers gate this on
69
+ `User.Read.All` before calling it (spec §8.6, §4.4 — the branch-level gate lives in the
70
+ command layer, not here).
71
+ """
72
+ start = client.get(_user_path(upn), params={"$select": CHAIN_SELECT})
73
+ levels = [start]
74
+ current_id = start.get("id")
75
+ while current_id and len(levels) <= max_levels:
76
+ try:
77
+ nxt = client.get(
78
+ f"{odata.p('users', current_id)}/manager", params={"$select": CHAIN_SELECT}
79
+ )
80
+ except GraphError as exc:
81
+ if exc.status == 404:
82
+ break
83
+ raise
84
+ levels.append(nxt)
85
+ current_id = nxt.get("id")
86
+ return levels