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,176 @@
1
+ """Mailbox settings, automatic replies and the focused inbox (spec §8.3)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import (
8
+ AllFlag,
9
+ DryRunFlag,
10
+ JsonFlag,
11
+ LimitOpt,
12
+ graph_command,
13
+ make_noun_app,
14
+ page_bounds,
15
+ )
16
+ from mgraphctl.errors import UsageError
17
+ from mgraphctl.graph import mail, mailbox
18
+ from mgraphctl.html import to_markdown
19
+ from mgraphctl.http import GraphClient
20
+ from mgraphctl.render import (
21
+ DryRunResult,
22
+ ListResult,
23
+ ObjectResult,
24
+ Window,
25
+ WriteResult,
26
+ fmt_dtz,
27
+ parse_dt,
28
+ )
29
+
30
+ app = make_noun_app("Mailbox settings, automatic replies, focused inbox.")
31
+ oof = make_noun_app("Automatic replies (out of office).")
32
+ app.add_typer(oof, name="oof")
33
+
34
+
35
+ def _working_hours(settings: dict) -> str:
36
+ """`Mon,Tue,Wed,Thu,Fri 08:00-17:00 (Europe/Warsaw)`, or "" when Graph sent nothing."""
37
+ hours = settings.get("workingHours") or {}
38
+ days = hours.get("daysOfWeek") or []
39
+ if not days:
40
+ return ""
41
+ zone = (hours.get("timeZone") or {}).get("name") or ""
42
+ labels = ",".join(str(day)[:3].capitalize() for day in days)
43
+ start = str(hours.get("startTime") or "")[:5]
44
+ end = str(hours.get("endTime") or "")[:5]
45
+ return f"{labels} {start}-{end} ({zone})"
46
+
47
+
48
+ OOF_MESSAGES = (
49
+ ("Internal reply", "internalReplyMessage"),
50
+ ("External reply", "externalReplyMessage"),
51
+ )
52
+
53
+
54
+ def _oof_body(setting: dict) -> str | None:
55
+ """The reply messages as text, one labelled block each."""
56
+ blocks = [
57
+ f"{label}\n{to_markdown(setting[key], 'mail')}"
58
+ for label, key in OOF_MESSAGES
59
+ if setting.get(key)
60
+ ]
61
+ return "\n\n".join(blocks) if blocks else None
62
+
63
+
64
+ @app.command("settings")
65
+ @graph_command(scopes=["MailboxSettings.Read"])
66
+ def settings(client: GraphClient, json_: JsonFlag = False):
67
+ """Show the mailbox's time zone, language, working hours and reply status."""
68
+ obj = mailbox.get_settings(client)
69
+ return ObjectResult(
70
+ obj=obj,
71
+ fields=[
72
+ ("Time zone", "timeZone"),
73
+ ("Language", "language.displayName"),
74
+ ("Working hours", _working_hours),
75
+ ("Automatic replies", "automaticRepliesSetting.status"),
76
+ ("Date format", "dateFormat"),
77
+ ("Time format", "timeFormat"),
78
+ ],
79
+ )
80
+
81
+
82
+ @oof.command("get")
83
+ @graph_command(scopes=["MailboxSettings.Read"])
84
+ def oof_get(client: GraphClient, json_: JsonFlag = False):
85
+ """Show the automatic replies setting."""
86
+ setting = mailbox.get_oof(client)
87
+ tz = client.tz
88
+ return ObjectResult(
89
+ obj=setting,
90
+ fields=[
91
+ ("Status", "status"),
92
+ ("External audience", "externalAudience"),
93
+ ("Start", lambda s: fmt_dtz(s.get("scheduledStartDateTime"), tz)),
94
+ ("End", lambda s: fmt_dtz(s.get("scheduledEndDateTime"), tz)),
95
+ ],
96
+ body=_oof_body(setting),
97
+ )
98
+
99
+
100
+ @oof.command("set")
101
+ @graph_command(scopes=["MailboxSettings.ReadWrite"])
102
+ def oof_set(
103
+ client: GraphClient,
104
+ message: Annotated[str | None, typer.Option("--message", help="Internal reply text.")] = None,
105
+ external_message: Annotated[
106
+ str | None, typer.Option("--external-message", help="External reply (default: --message).")
107
+ ] = None,
108
+ start: Annotated[str | None, typer.Option("--start", help="Start of the period.")] = None,
109
+ end: Annotated[str | None, typer.Option("--end", help="End of the period.")] = None,
110
+ external: Annotated[
111
+ str | None,
112
+ typer.Option("--external", help="Who outside sees a reply: all, contacts, none."),
113
+ ] = None,
114
+ internal_only: Annotated[
115
+ bool, typer.Option("--internal-only", help="Same as --external none.")
116
+ ] = False,
117
+ clear: Annotated[bool, typer.Option("--clear", help="Turn automatic replies off.")] = False,
118
+ dry_run: DryRunFlag = False,
119
+ json_: JsonFlag = False,
120
+ ):
121
+ """Turn automatic replies on (always or for a period) or off."""
122
+ tz = client.tz
123
+ if not clear and not message:
124
+ raise UsageError("USAGE", "--message is required unless --clear is given")
125
+ if (start is None) != (end is None):
126
+ raise UsageError("USAGE", "--start and --end go together; give both or neither")
127
+ if external is not None and external not in mailbox.AUDIENCE:
128
+ raise UsageError(
129
+ "USAGE",
130
+ f"--external must be one of {', '.join(mailbox.AUDIENCE)} (got {external!r})",
131
+ )
132
+ if internal_only and external not in (None, "none"):
133
+ raise UsageError("USAGE", "--internal-only and --external are mutually exclusive")
134
+ audience = "none" if internal_only else mailbox.AUDIENCE[external or "all"]
135
+ plan = mailbox.plan_set_oof(
136
+ client,
137
+ message=message,
138
+ external_message=external_message,
139
+ start=parse_dt(start, tz) if start else None,
140
+ end=parse_dt(end, tz, end_of_day=True) if end else None,
141
+ audience=audience,
142
+ clear=clear,
143
+ tz=tz,
144
+ )
145
+ if dry_run:
146
+ return DryRunResult(plan)
147
+ updated = mail.run_plan(client, plan)[0]
148
+ return WriteResult(obj=updated, message="Automatic replies updated.")
149
+
150
+
151
+ @app.command("focused")
152
+ @graph_command(scopes=["Mail.Read"])
153
+ def focused(
154
+ client: GraphClient,
155
+ other: Annotated[
156
+ bool, typer.Option("--other", help="Show the Other inbox instead of Focused.")
157
+ ] = False,
158
+ after: Annotated[
159
+ str, typer.Option("--after", help="Only messages received after this.")
160
+ ] = "-30d",
161
+ limit: LimitOpt = None,
162
+ all_: AllFlag = False,
163
+ json_: JsonFlag = False,
164
+ ):
165
+ """List Focused (or Other) inbox messages, newest first."""
166
+ limit, all_ = page_bounds(limit, all_, default=25)
167
+ tz = client.tz
168
+ after_dt = parse_dt(after, tz)
169
+ page = mailbox.list_focused(client, other=other, after=after_dt, limit=limit, all_=all_)
170
+ return ListResult(
171
+ items=page.items,
172
+ page=page,
173
+ window=Window(after=after_dt),
174
+ hit_cap=mail.CAP_LIST if all_ else None,
175
+ columns=mail.message_columns(tz),
176
+ )
@@ -0,0 +1,294 @@
1
+ """Online meetings, transcripts and AI insights (spec §8.10)."""
2
+
3
+ from datetime import timedelta
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from mgraphctl import auth, render
10
+ from mgraphctl.cli import JsonFlag, LimitOpt, gate, graph_command, make_noun_app
11
+ from mgraphctl.errors import UsageError
12
+ from mgraphctl.graph import meetings
13
+ from mgraphctl.html import vtt_to_speaker_turns, vtt_to_text
14
+ from mgraphctl.http import GraphClient
15
+ from mgraphctl.render import (
16
+ Column,
17
+ FileResult,
18
+ ListResult,
19
+ ObjectResult,
20
+ TextResult,
21
+ Window,
22
+ fmt_dt,
23
+ fmt_dtz,
24
+ fmt_size,
25
+ truncate,
26
+ )
27
+
28
+ app = make_noun_app("Online meetings, transcripts and AI insights.")
29
+
30
+ DEFAULT_LIMIT = 50
31
+ FORMATS = ("text", "vtt")
32
+
33
+ MeetingArg = Annotated[str | None, typer.Argument(metavar="MEETING", help="An online meeting id.")]
34
+ JoinUrlOpt = Annotated[
35
+ str | None, typer.Option("--join-url", help="Select the meeting by its join URL.")
36
+ ]
37
+ EventOpt = Annotated[
38
+ str | None, typer.Option("--event", help="Select the meeting from a calendar event id.")
39
+ ]
40
+
41
+ GET_FIELDS = [
42
+ ("Meeting id", "id"),
43
+ ("Subject", "subject"),
44
+ ("Join URL", "joinWebUrl"),
45
+ ]
46
+
47
+
48
+ def _window(start: str | None, end: str | None, tz: str) -> tuple:
49
+ """The `[start, end]` window: explicit `--start`/`--end`, else the last 7 days (spec §8.10)."""
50
+ start_dt = (
51
+ render.parse_dt(start, tz) if start else render.parse_dt("today", tz) - timedelta(days=7)
52
+ )
53
+ end_dt = (
54
+ render.parse_dt(end, tz, end_of_day=True)
55
+ if end
56
+ else render.parse_dt("today", tz, end_of_day=True)
57
+ )
58
+ return start_dt, end_dt
59
+
60
+
61
+ @app.command("list")
62
+ @graph_command(scopes=["Calendars.Read"])
63
+ def list_(
64
+ client: GraphClient,
65
+ start: Annotated[str | None, typer.Option("--start", help="Start of the window.")] = None,
66
+ end: Annotated[str | None, typer.Option("--end", help="End of the window.")] = None,
67
+ subject: Annotated[
68
+ str | None, typer.Option("--subject", help="Case-insensitive subject substring.")
69
+ ] = None,
70
+ resolve: Annotated[
71
+ bool, typer.Option("--resolve", help="Resolve each event to its online meeting id.")
72
+ ] = False,
73
+ with_transcripts: Annotated[
74
+ bool,
75
+ typer.Option("--with-transcripts", help="Also list transcript ids (implies --resolve)."),
76
+ ] = False,
77
+ limit: LimitOpt = None,
78
+ json_: JsonFlag = False,
79
+ ):
80
+ """List calendar events that are online meetings (default window: the last 7 days)."""
81
+ tz = client.tz
82
+ start_dt, end_dt = _window(start, end, tz)
83
+ bound = limit if limit is not None else DEFAULT_LIMIT
84
+ page = meetings.list_online_events(
85
+ client, start=start_dt, end=end_dt, subject=subject, limit=bound, tz=tz
86
+ )
87
+ events = page.items
88
+ do_resolve = resolve or with_transcripts
89
+ resolved: dict[str, dict] = {}
90
+ if do_resolve:
91
+ gate(["OnlineMeetings.Read"])
92
+ resolved = meetings.resolve_meetings(client, events)
93
+ transcripts: dict[str, list] = {}
94
+ if with_transcripts:
95
+ gate(["OnlineMeetingTranscript.Read.All"])
96
+ meeting_ids = [m["id"] for m in resolved.values() if m.get("id")]
97
+ transcripts = meetings.transcripts_for(client, meeting_ids)
98
+ items = []
99
+ for event in events:
100
+ meeting = resolved.get(event["id"])
101
+ meeting_id = meeting.get("id") if meeting else None
102
+ items.append(
103
+ {
104
+ "eventId": event.get("id"),
105
+ "subject": event.get("subject"),
106
+ "start": event.get("start"),
107
+ "end": event.get("end"),
108
+ "organizer": event.get("organizer"),
109
+ "meetingId": meeting_id if do_resolve else None,
110
+ "transcriptIds": (
111
+ [t.get("id") for t in transcripts.get(meeting_id, [])]
112
+ if with_transcripts and meeting_id
113
+ else []
114
+ ),
115
+ }
116
+ )
117
+ return ListResult(
118
+ items=items,
119
+ page=page,
120
+ window=Window(after=start_dt, before=end_dt),
121
+ supports_all=False,
122
+ columns=[
123
+ Column("start", lambda it: fmt_dtz(it.get("start"), tz)),
124
+ Column("subject", lambda it: truncate(it.get("subject"))),
125
+ Column("event_id", "eventId"),
126
+ Column("meeting_id", "meetingId"),
127
+ Column("transcripts", "transcriptIds"),
128
+ ],
129
+ )
130
+
131
+
132
+ @app.command("get")
133
+ @graph_command(scopes=["OnlineMeetings.Read"])
134
+ def get(
135
+ client: GraphClient,
136
+ meeting: MeetingArg = None,
137
+ join_url: JoinUrlOpt = None,
138
+ event: EventOpt = None,
139
+ json_: JsonFlag = False,
140
+ ):
141
+ """Show one online meeting."""
142
+ obj = meetings.get_meeting(client, meeting, join_url=join_url, event=event)
143
+ return ObjectResult(obj=obj, fields=list(GET_FIELDS))
144
+
145
+
146
+ @app.command("transcripts")
147
+ @graph_command(scopes=["OnlineMeetingTranscript.Read.All"])
148
+ def transcripts(
149
+ client: GraphClient,
150
+ meeting: MeetingArg = None,
151
+ join_url: JoinUrlOpt = None,
152
+ event: EventOpt = None,
153
+ json_: JsonFlag = False,
154
+ ):
155
+ """List the transcripts recorded for one online meeting."""
156
+ selected = meetings.select_meeting(client, meeting, join_url, event)
157
+ items = meetings.list_transcripts(client, selected["id"])
158
+ tz = client.tz
159
+ return ListResult(
160
+ items=items,
161
+ columns=[
162
+ Column("id", "id"),
163
+ Column("created", lambda t: fmt_dt(t.get("createdDateTime"), tz)),
164
+ ],
165
+ )
166
+
167
+
168
+ @app.command("transcript")
169
+ @graph_command(scopes=["OnlineMeetingTranscript.Read.All"])
170
+ def transcript(
171
+ client: GraphClient,
172
+ ids: Annotated[
173
+ list[str] | None,
174
+ typer.Argument(
175
+ metavar="[MEETING] TRANSCRIPT_ID", help="The meeting id, then the transcript id."
176
+ ),
177
+ ] = None,
178
+ join_url: JoinUrlOpt = None,
179
+ event: EventOpt = None,
180
+ fmt: Annotated[str, typer.Option("--format", help="text or vtt.")] = "text",
181
+ speakers: Annotated[
182
+ bool, typer.Option("--speakers", help="Merge each speaker's consecutive cues into a turn.")
183
+ ] = False,
184
+ output: Annotated[
185
+ Path | None, typer.Option("--output", help="Write to this file instead of stdout.")
186
+ ] = None,
187
+ json_: JsonFlag = False,
188
+ ):
189
+ """Show one transcript's content."""
190
+ tokens = ids or []
191
+ if len(tokens) == 2:
192
+ meeting, transcript_id = tokens
193
+ elif len(tokens) == 1:
194
+ meeting, transcript_id = None, tokens[0]
195
+ else:
196
+ raise UsageError("USAGE", "give TRANSCRIPT_ID, and MEETING or --join-url or --event")
197
+ if fmt not in FORMATS:
198
+ raise UsageError("USAGE", f"--format must be one of {', '.join(FORMATS)}")
199
+ if speakers and fmt == "vtt":
200
+ raise UsageError(
201
+ "USAGE", "--speakers renders text; it cannot be combined with --format vtt"
202
+ )
203
+ selected = meetings.select_meeting(client, meeting, join_url, event)
204
+ meeting_id = selected["id"]
205
+ body = meetings.get_transcript_body(client, meeting_id, transcript_id)
206
+ # What Graph returned, not what was asked for: the speaker-attribution fallback answers in
207
+ # plain text even to a vtt request, and some meeting types answer in vtt to a text one.
208
+ is_vtt = meetings.looks_like_vtt(body)
209
+ if speakers:
210
+ text, shape = (vtt_to_speaker_turns(body) if is_vtt else body), "text"
211
+ elif fmt == "vtt":
212
+ text, shape = body, ("vtt" if is_vtt else "text")
213
+ else:
214
+ text, shape = (vtt_to_text(body) if is_vtt else body), "text"
215
+ if output is not None:
216
+ output.parent.mkdir(parents=True, exist_ok=True)
217
+ output.write_text(text, encoding="utf-8")
218
+ size = len(text.encode("utf-8"))
219
+ return FileResult(
220
+ path=output,
221
+ bytes=size,
222
+ meta=dict(meetingId=meeting_id, transcriptId=transcript_id, format=shape),
223
+ message=f"Wrote the {shape} transcript ({fmt_size(size)}) to {output}",
224
+ )
225
+ # `createdDateTime` costs one extra request, so only the path that emits it pays.
226
+ created = meetings.transcript_created_at(client, meeting_id, transcript_id) if json_ else None
227
+ payload = dict(
228
+ meetingId=meeting_id,
229
+ transcriptId=transcript_id,
230
+ createdDateTime=created,
231
+ format=shape,
232
+ content=text,
233
+ )
234
+ return TextResult(text=text, json_obj=payload)
235
+
236
+
237
+ @app.command("insights")
238
+ @graph_command(scopes=["OnlineMeetingAiInsight.Read.All"])
239
+ def insights(
240
+ client: GraphClient,
241
+ meeting: MeetingArg = None,
242
+ join_url: JoinUrlOpt = None,
243
+ event: EventOpt = None,
244
+ json_: JsonFlag = False,
245
+ ):
246
+ """AI-generated meeting insights (recap, action items, mentions)."""
247
+ selected = meetings.select_meeting(client, meeting, join_url, event)
248
+ oid = auth.my_oid()
249
+ items, note = meetings.insights(client, oid, selected["id"])
250
+ return ListResult(
251
+ items=items,
252
+ columns=[Column("id", "id"), Column("title", "title")],
253
+ empty_text=note or "No results.",
254
+ extra={"note": note} if note else None,
255
+ )
256
+
257
+
258
+ @app.command("recordings")
259
+ @graph_command(scopes=["OnlineMeetingRecording.Read.All"])
260
+ def recordings(
261
+ client: GraphClient,
262
+ meeting: MeetingArg = None,
263
+ join_url: JoinUrlOpt = None,
264
+ event: EventOpt = None,
265
+ download: Annotated[
266
+ str | None, typer.Option("--download", metavar="RID", help="Recording id to download.")
267
+ ] = None,
268
+ output: Annotated[
269
+ Path | None, typer.Option("--output", help="File to write the recording to.")
270
+ ] = None,
271
+ json_: JsonFlag = False,
272
+ ):
273
+ """List recordings for one online meeting, or download one."""
274
+ selected = meetings.select_meeting(client, meeting, join_url, event)
275
+ meeting_id = selected["id"]
276
+ if download is not None or output is not None:
277
+ if download is None or output is None:
278
+ raise UsageError("USAGE", "--download and --output must be given together")
279
+ got = meetings.download_recording(client, meeting_id, download, output)
280
+ return FileResult(
281
+ path=got.path,
282
+ bytes=got.bytes,
283
+ meta=dict(contentType=got.content_type),
284
+ message=f"Downloaded recording ({fmt_size(got.bytes)}) to {got.path}",
285
+ )
286
+ items = meetings.list_recordings(client, meeting_id)
287
+ tz = client.tz
288
+ return ListResult(
289
+ items=items,
290
+ columns=[
291
+ Column("id", "id"),
292
+ Column("created", lambda r: fmt_dt(r.get("createdDateTime"), tz)),
293
+ ],
294
+ )