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,201 @@
1
+ """People, contacts and directory users (spec §8.5)."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from mgraphctl import auth
9
+ from mgraphctl.cli import (
10
+ AllFlag,
11
+ JsonFlag,
12
+ LimitOpt,
13
+ gate,
14
+ graph_command,
15
+ make_noun_app,
16
+ page_bounds,
17
+ )
18
+ from mgraphctl.errors import AuthError
19
+ from mgraphctl.graph import people
20
+ from mgraphctl.graph import users as graph_users
21
+ from mgraphctl.http import GraphClient
22
+ from mgraphctl.render import Column, FileResult, ListResult, ObjectResult, fmt_size
23
+
24
+ app = make_noun_app("People, contacts and directory users.")
25
+
26
+ CONTACT_FIELDS = [
27
+ ("Name", "displayName"),
28
+ ("Email", lambda c: _first_email(c)),
29
+ ("Mobile", "mobilePhone"),
30
+ ("Business phones", lambda c: ", ".join(c.get("businessPhones") or [])),
31
+ ("Title", "jobTitle"),
32
+ ("Company", "companyName"),
33
+ ("Contact id", "id"),
34
+ ]
35
+
36
+ USER_FIELDS = [
37
+ ("Name", "displayName"),
38
+ ("UPN", "userPrincipalName"),
39
+ ("Mail", "mail"),
40
+ ("Title", "jobTitle"),
41
+ ("Dept", "department"),
42
+ ("Office", "officeLocation"),
43
+ ("Phones", lambda u: ", ".join(u.get("businessPhones") or [])),
44
+ ("Mobile", "mobilePhone"),
45
+ ("User id", "id"),
46
+ ]
47
+
48
+
49
+ def _first_scored_email(p: dict) -> str:
50
+ emails = p.get("scoredEmailAddresses") or []
51
+ return emails[0].get("address", "") if emails else ""
52
+
53
+
54
+ def _first_email(c: dict) -> str:
55
+ emails = c.get("emailAddresses") or []
56
+ return emails[0].get("address", "") if emails else ""
57
+
58
+
59
+ def _first_phone(c: dict) -> str:
60
+ phones = c.get("businessPhones") or []
61
+ return c.get("mobilePhone") or (phones[0] if phones else "")
62
+
63
+
64
+ def _has_scope(scopes: list[str]) -> bool:
65
+ """Whether the current token already carries `scopes`, without failing the command."""
66
+ try:
67
+ gate(scopes)
68
+ except AuthError:
69
+ return False
70
+ return True
71
+
72
+
73
+ @app.command("search")
74
+ @graph_command(scopes=["People.Read"])
75
+ def search(
76
+ client: GraphClient,
77
+ q: Annotated[str, typer.Argument(metavar="Q")],
78
+ limit: LimitOpt = None,
79
+ all_: AllFlag = False,
80
+ json_: JsonFlag = False,
81
+ ):
82
+ """Search people relevant to the signed-in user (`/me/people`)."""
83
+ limit, all_ = page_bounds(limit, all_, default=20)
84
+ page = people.search_people(client, q, limit=limit, all_=all_)
85
+ return ListResult(
86
+ items=page.items,
87
+ page=page,
88
+ hit_cap=people.CAP if all_ else None,
89
+ columns=[
90
+ Column("id", "id"),
91
+ Column("name", "displayName"),
92
+ Column("email", _first_scored_email),
93
+ Column("title", "jobTitle"),
94
+ Column("department", "department"),
95
+ Column("type", lambda p: (p.get("personType") or {}).get("class", "")),
96
+ ],
97
+ )
98
+
99
+
100
+ @app.command("contacts")
101
+ @graph_command(scopes=["Contacts.Read"])
102
+ def contacts(
103
+ client: GraphClient,
104
+ search: Annotated[str | None, typer.Option("--search", help="Filter by name or email.")] = None,
105
+ limit: LimitOpt = None,
106
+ all_: AllFlag = False,
107
+ json_: JsonFlag = False,
108
+ ):
109
+ """List the signed-in user's contacts."""
110
+ limit, all_ = page_bounds(limit, all_, default=20)
111
+ page = people.list_contacts(client, search=search, limit=limit, all_=all_)
112
+ return ListResult(
113
+ items=page.items,
114
+ page=page,
115
+ hit_cap=people.CAP if all_ else None,
116
+ columns=[
117
+ Column("id", "id"),
118
+ Column("name", "displayName"),
119
+ Column("email", _first_email),
120
+ Column("phone", _first_phone),
121
+ Column("title", "jobTitle"),
122
+ Column("company", "companyName"),
123
+ ],
124
+ )
125
+
126
+
127
+ @app.command("contact")
128
+ @graph_command(scopes=["Contacts.Read"])
129
+ def contact(
130
+ client: GraphClient,
131
+ contact_id: Annotated[str, typer.Argument(metavar="ID")],
132
+ json_: JsonFlag = False,
133
+ ):
134
+ """Show one contact."""
135
+ return ObjectResult(obj=people.get_contact(client, contact_id), fields=list(CONTACT_FIELDS))
136
+
137
+
138
+ @app.command("users")
139
+ @graph_command(scopes=["User.ReadBasic.All"])
140
+ def users(
141
+ client: GraphClient,
142
+ q: Annotated[str, typer.Argument(metavar="Q")],
143
+ limit: LimitOpt = None,
144
+ all_: AllFlag = False,
145
+ json_: JsonFlag = False,
146
+ ):
147
+ """Search the directory for users (`/users`, tokenised `$search`)."""
148
+ limit, all_ = page_bounds(limit, all_, default=20)
149
+ page = graph_users.search_users(client, q, limit=limit, all_=all_)
150
+ return ListResult(
151
+ items=page.items,
152
+ page=page,
153
+ hit_cap=graph_users.CAP if all_ else None,
154
+ columns=[
155
+ Column("id", "id"),
156
+ Column("name", "displayName"),
157
+ Column("upn", "userPrincipalName"),
158
+ Column("mail", "mail"),
159
+ Column("title", "jobTitle"),
160
+ Column("department", "department"),
161
+ ],
162
+ )
163
+
164
+
165
+ @app.command("user")
166
+ @graph_command(scopes=["User.Read"])
167
+ def user(
168
+ client: GraphClient,
169
+ ref: Annotated[str, typer.Argument(metavar="UPN|ID")],
170
+ json_: JsonFlag = False,
171
+ ):
172
+ """Look up one org user, by UPN, id, `me`, or (with User.ReadBasic.All) display name."""
173
+ can_search = _has_scope(["User.ReadBasic.All"])
174
+ obj = graph_users.resolve_user(client, ref, can_search=can_search)
175
+ return ObjectResult(obj=obj, fields=list(USER_FIELDS))
176
+
177
+
178
+ @app.command("photo")
179
+ @graph_command(scopes=["User.Read"])
180
+ def photo(
181
+ client: GraphClient,
182
+ upn: Annotated[str | None, typer.Argument(metavar="UPN")] = None,
183
+ output: Annotated[Path | None, typer.Option("--output", help="Destination file.")] = None,
184
+ size: Annotated[str, typer.Option("--size", help="Photo size (others only).")] = "96x96",
185
+ json_: JsonFlag = False,
186
+ ):
187
+ """Download a profile photo (self by default)."""
188
+ if upn is not None:
189
+ gate(["User.ReadBasic.All"])
190
+ stem = upn
191
+ else:
192
+ claims = auth.decode_jwt(auth.get_access_token(False))
193
+ stem = claims.get("upn") or "me"
194
+ dest = output or Path(f"{stem}.jpg")
195
+ got = people.download_photo(client, upn, size, dest)
196
+ return FileResult(
197
+ path=got.path,
198
+ bytes=got.bytes,
199
+ meta=dict(contentType=got.content_type),
200
+ message=f"Downloaded photo ({fmt_size(got.bytes)}) to {got.path}",
201
+ )
@@ -0,0 +1,268 @@
1
+ """Planner commands: plans, buckets, tasks (spec §8.14)."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import DryRunFlag, JsonFlag, LimitOpt, graph_command, make_noun_app
8
+ from mgraphctl.errors import UsageError
9
+ from mgraphctl.graph import planner, users
10
+ from mgraphctl.http import GraphClient
11
+ from mgraphctl.render import (
12
+ Column,
13
+ DryRunResult,
14
+ ListResult,
15
+ ObjectResult,
16
+ WriteResult,
17
+ fmt_dt,
18
+ note,
19
+ parse_dt,
20
+ )
21
+
22
+ app = make_noun_app("Planner plans and tasks.")
23
+
24
+ SCOPES = ["Tasks.ReadWrite"]
25
+ SCOPES_PLANS = ["Tasks.ReadWrite", "Group.Read.All"]
26
+ DEFAULT_LIMIT = 50
27
+
28
+ PLAN_COLUMNS = [
29
+ Column("id", "id"),
30
+ Column("title", "title"),
31
+ Column("group", lambda p: p.get("_groupName") or ""),
32
+ ]
33
+ BUCKET_COLUMNS = [Column("id", "id"), Column("name", "name")]
34
+
35
+
36
+ def _task_columns(tz: str, *, my: bool) -> list[Column]:
37
+ columns = [
38
+ Column("id", "id"),
39
+ Column("%", lambda t: t.get("percentComplete", "")),
40
+ Column("priority", lambda t: t.get("priority", "")),
41
+ Column("due", lambda t: fmt_dt(t.get("dueDateTime"), tz)),
42
+ ]
43
+ if my:
44
+ columns.append(Column("plan", lambda t: t.get("_planTitle") or ""))
45
+ else:
46
+ columns.append(Column("bucket", lambda t: t.get("_bucketName") or ""))
47
+ columns.append(Column("title", "title"))
48
+ return columns
49
+
50
+
51
+ def _assignee_oids(client: GraphClient, upns: list[str]) -> list[str]:
52
+ return [users.get_user(client, upn, select="id")["id"] for upn in upns]
53
+
54
+
55
+ @app.command("plans")
56
+ @graph_command(scopes=SCOPES_PLANS)
57
+ def plans(
58
+ client: GraphClient,
59
+ limit: LimitOpt = None,
60
+ json_: JsonFlag = False,
61
+ ):
62
+ """List plans you own plus every plan of a Microsoft 365 group you belong to."""
63
+ eff_limit = limit if limit is not None else DEFAULT_LIMIT
64
+ page = planner.list_plans(client, limit=eff_limit, on_skip=note)
65
+ return ListResult(items=page.items, columns=PLAN_COLUMNS, page=page, supports_all=False)
66
+
67
+
68
+ @app.command("plan")
69
+ @graph_command(scopes=SCOPES)
70
+ def plan(
71
+ client: GraphClient,
72
+ plan_ref: Annotated[str, typer.Argument(metavar="PLAN")],
73
+ json_: JsonFlag = False,
74
+ ):
75
+ """Show one plan, with its details."""
76
+ resolved = planner.resolve_plan(client, plan_ref)
77
+ obj = planner.get_plan(client, resolved["id"])
78
+ fields = [
79
+ ("Title", "title"),
80
+ ("Plan id", "id"),
81
+ ("Created", lambda o: fmt_dt(o.get("createdDateTime"), client.tz)),
82
+ ("Description", lambda o: (o.get("details") or {}).get("description") or ""),
83
+ ]
84
+ return ObjectResult(obj=obj, fields=fields)
85
+
86
+
87
+ @app.command("buckets")
88
+ @graph_command(scopes=SCOPES)
89
+ def buckets(
90
+ client: GraphClient,
91
+ plan_ref: Annotated[str, typer.Argument(metavar="PLAN")],
92
+ json_: JsonFlag = False,
93
+ ):
94
+ """List the buckets of a plan."""
95
+ resolved = planner.resolve_plan(client, plan_ref)
96
+ items = planner.list_buckets(client, resolved["id"])
97
+ return ListResult(items=items, columns=BUCKET_COLUMNS)
98
+
99
+
100
+ @app.command("tasks")
101
+ @graph_command(scopes=SCOPES)
102
+ def tasks(
103
+ client: GraphClient,
104
+ plan_ref: Annotated[str | None, typer.Argument(metavar="PLAN")] = None,
105
+ my: Annotated[bool, typer.Option("--my", help="Your tasks across every plan.")] = False,
106
+ bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
107
+ include_completed: Annotated[bool, typer.Option("--include-completed")] = False,
108
+ limit: LimitOpt = None,
109
+ json_: JsonFlag = False,
110
+ ):
111
+ """List tasks in a plan, or your own tasks across every plan (--my)."""
112
+ eff_limit = limit if limit is not None else DEFAULT_LIMIT
113
+ if my:
114
+ page = planner.my_tasks(
115
+ client, include_completed=include_completed, limit=eff_limit, on_skip=note
116
+ )
117
+ return ListResult(
118
+ items=page.items,
119
+ columns=_task_columns(client.tz, my=True),
120
+ page=page,
121
+ supports_all=False,
122
+ )
123
+ if plan_ref is None:
124
+ raise UsageError("USAGE", "PLAN is required unless --my is given")
125
+ resolved = planner.resolve_plan(client, plan_ref)
126
+ bucket_id = None
127
+ if bucket is not None:
128
+ bucket_id = planner.resolve_bucket(client, resolved["id"], bucket)["id"]
129
+ page = planner.list_tasks(
130
+ client,
131
+ resolved["id"],
132
+ bucket_id=bucket_id,
133
+ include_completed=include_completed,
134
+ limit=eff_limit,
135
+ )
136
+ return ListResult(
137
+ items=page.items,
138
+ columns=_task_columns(client.tz, my=False),
139
+ page=page,
140
+ supports_all=False,
141
+ )
142
+
143
+
144
+ @app.command("task")
145
+ @graph_command(scopes=SCOPES)
146
+ def task(
147
+ client: GraphClient,
148
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
149
+ json_: JsonFlag = False,
150
+ ):
151
+ """Show one task, with its details."""
152
+ obj = planner.get_task(client, task_id)
153
+ fields = [
154
+ ("Title", "title"),
155
+ ("Plan id", "planId"),
156
+ ("Bucket id", "bucketId"),
157
+ ("%", "percentComplete"),
158
+ ("Priority", "priority"),
159
+ ("Due", lambda o: fmt_dt(o.get("dueDateTime"), client.tz)),
160
+ ("Description", lambda o: (o.get("details") or {}).get("description") or ""),
161
+ ]
162
+ return ObjectResult(obj=obj, fields=fields)
163
+
164
+
165
+ @app.command("create")
166
+ @graph_command(scopes=SCOPES)
167
+ def create(
168
+ client: GraphClient,
169
+ plan_opt: Annotated[str, typer.Option("--plan", help="Plan name or id.")],
170
+ title: Annotated[str, typer.Option("--title")],
171
+ bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
172
+ due: Annotated[str | None, typer.Option("--due")] = None,
173
+ assign: Annotated[
174
+ list[str] | None, typer.Option("--assign", help="Assignee UPN (repeatable).")
175
+ ] = None,
176
+ priority: Annotated[int | None, typer.Option("--priority", min=0, max=10)] = None,
177
+ description: Annotated[str | None, typer.Option("--description")] = None,
178
+ dry_run: DryRunFlag = False,
179
+ json_: JsonFlag = False,
180
+ ):
181
+ """Create a task."""
182
+ resolved_plan = planner.resolve_plan(client, plan_opt)
183
+ bucket_id = None
184
+ if bucket is not None:
185
+ bucket_id = planner.resolve_bucket(client, resolved_plan["id"], bucket)["id"]
186
+ due_dt = parse_dt(due, client.tz) if due is not None else None
187
+ oids = _assignee_oids(client, assign or [])
188
+ kwargs = dict(
189
+ plan_id=resolved_plan["id"],
190
+ bucket_id=bucket_id,
191
+ title=title,
192
+ due=due_dt,
193
+ assignee_oids=oids,
194
+ priority=priority,
195
+ description=description,
196
+ )
197
+ if dry_run:
198
+ return DryRunResult(planner.plan_create_task(client, **kwargs))
199
+ obj = planner.run_create_task(client, **kwargs)
200
+ return WriteResult(obj=obj, message=f"Created task {obj.get('id')}.")
201
+
202
+
203
+ @app.command("update")
204
+ @graph_command(scopes=SCOPES)
205
+ def update(
206
+ client: GraphClient,
207
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
208
+ title: Annotated[str | None, typer.Option("--title")] = None,
209
+ due: Annotated[str | None, typer.Option("--due")] = None,
210
+ percent: Annotated[
211
+ Literal[0, 50, 100] | None,
212
+ typer.Option("--percent", help="Completion; Graph accepts only these three values."),
213
+ ] = None,
214
+ bucket: Annotated[str | None, typer.Option("--bucket", help="Bucket name or id.")] = None,
215
+ priority: Annotated[int | None, typer.Option("--priority", min=0, max=10)] = None,
216
+ assign: Annotated[list[str] | None, typer.Option("--assign")] = None,
217
+ unassign: Annotated[list[str] | None, typer.Option("--unassign")] = None,
218
+ description: Annotated[str | None, typer.Option("--description")] = None,
219
+ dry_run: DryRunFlag = False,
220
+ json_: JsonFlag = False,
221
+ ):
222
+ """Update a task."""
223
+ due_dt = parse_dt(due, client.tz) if due is not None else None
224
+ bucket_id = planner.resolve_bucket_for_task(client, task_id, bucket)["id"] if bucket else None
225
+ kwargs = dict(
226
+ title=title,
227
+ due=due_dt,
228
+ percent=percent,
229
+ bucket_id=bucket_id,
230
+ priority=priority,
231
+ assign_oids=_assignee_oids(client, assign or []),
232
+ unassign_oids=_assignee_oids(client, unassign or []),
233
+ description=description,
234
+ )
235
+ if dry_run:
236
+ return DryRunResult(planner.plan_update_task(client, task_id, **kwargs))
237
+ obj = planner.run_update_task(client, task_id, **kwargs)
238
+ return WriteResult(obj=obj, message=f"Updated task {task_id}.")
239
+
240
+
241
+ @app.command("complete")
242
+ @graph_command(scopes=SCOPES)
243
+ def complete(
244
+ client: GraphClient,
245
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
246
+ dry_run: DryRunFlag = False,
247
+ json_: JsonFlag = False,
248
+ ):
249
+ """Mark a task complete (percent 100)."""
250
+ if dry_run:
251
+ return DryRunResult(planner.plan_update_task(client, task_id, percent=100))
252
+ obj = planner.run_update_task(client, task_id, percent=100)
253
+ return WriteResult(obj=obj, message=f"Completed task {task_id}.")
254
+
255
+
256
+ @app.command("delete")
257
+ @graph_command(scopes=SCOPES)
258
+ def delete(
259
+ client: GraphClient,
260
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
261
+ dry_run: DryRunFlag = False,
262
+ json_: JsonFlag = False,
263
+ ):
264
+ """Delete a task."""
265
+ if dry_run:
266
+ return DryRunResult(planner.plan_delete_task(client, task_id))
267
+ planner.run_delete_task(client, task_id)
268
+ return WriteResult(obj={"status": "deleted", "id": task_id}, message=f"Deleted task {task_id}.")
@@ -0,0 +1,102 @@
1
+ """Teams presence commands (spec §8.9)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl import auth
8
+ from mgraphctl.cli import DryRunFlag, JsonFlag, gate, graph_command, make_noun_app
9
+ from mgraphctl.graph import presence as presence_api
10
+ from mgraphctl.graph import users
11
+ from mgraphctl.http import GraphClient
12
+ from mgraphctl.render import (
13
+ Column,
14
+ DryRunResult,
15
+ ListResult,
16
+ ObjectResult,
17
+ WriteResult,
18
+ dig,
19
+ parse_duration,
20
+ )
21
+
22
+ OTHERS_SCOPES = ["Presence.Read.All"]
23
+ STATES = ", ".join(presence_api.PRESENCE_PAIRS)
24
+
25
+ app = make_noun_app("Teams presence.")
26
+
27
+
28
+ @app.command("get")
29
+ @graph_command(scopes=["Presence.Read"])
30
+ def get(
31
+ client: GraphClient,
32
+ user: Annotated[
33
+ list[str] | None,
34
+ typer.Argument(metavar="[USER...]", help="UPNs or user ids; omit for yourself."),
35
+ ] = None,
36
+ json_: JsonFlag = False,
37
+ ):
38
+ """Show your presence, or that of the given users."""
39
+ if not user:
40
+ return ObjectResult(
41
+ obj=presence_api.get_presence(client),
42
+ fields=[
43
+ ("Availability", "availability"),
44
+ ("Activity", "activity"),
45
+ ("Status", lambda p: dig(p, "statusMessage.message.content")),
46
+ ("User id", "id"),
47
+ ],
48
+ )
49
+ gate(OTHERS_SCOPES) # Reading anyone else needs the on-demand scope (spec §4.4).
50
+ people = [users.get_user(client, ref, select="id,displayName") for ref in user]
51
+ names = {p["id"]: p.get("displayName") for p in people}
52
+ return ListResult(
53
+ items=presence_api.get_presences(client, [p["id"] for p in people]),
54
+ empty_text="No presence information.",
55
+ columns=[
56
+ Column("id", "id"),
57
+ Column("name", lambda p: names.get(p.get("id"), "")),
58
+ Column("availability", "availability"),
59
+ Column("activity", "activity"),
60
+ ],
61
+ )
62
+
63
+
64
+ @app.command("set")
65
+ @graph_command(scopes=["Presence.ReadWrite"])
66
+ def set_(
67
+ client: GraphClient,
68
+ state: Annotated[str, typer.Argument(metavar="STATE", help=f"One of {STATES}.")],
69
+ expiration: Annotated[
70
+ str, typer.Option("--expiration", help="How long it lasts (30m, 2h, PT1H).")
71
+ ] = "1h",
72
+ message: Annotated[
73
+ str | None, typer.Option("--message", help="Status message to show alongside it.")
74
+ ] = None,
75
+ dry_run: DryRunFlag = False,
76
+ json_: JsonFlag = False,
77
+ ):
78
+ """Set your preferred presence."""
79
+ availability, _ = presence_api.resolve_pair(state)
80
+ plan = presence_api.plan_set(
81
+ client,
82
+ auth.my_oid(),
83
+ state,
84
+ expiration=parse_duration(expiration),
85
+ message=message,
86
+ )
87
+ if dry_run:
88
+ return DryRunResult(plan)
89
+ for step in plan:
90
+ client.execute(step)
91
+ return WriteResult(obj=None, message=f"Presence set to {availability}.")
92
+
93
+
94
+ @app.command("clear")
95
+ @graph_command(scopes=["Presence.ReadWrite"])
96
+ def clear(client: GraphClient, dry_run: DryRunFlag = False, json_: JsonFlag = False):
97
+ """Clear your preferred presence, handing it back to Teams."""
98
+ plan = presence_api.plan_clear(client, auth.my_oid())
99
+ if dry_run:
100
+ return DryRunResult(plan)
101
+ client.execute(plan[0])
102
+ return WriteResult(obj=None, message="Preferred presence cleared.")
@@ -0,0 +1,75 @@
1
+ """Unified search across Microsoft 365 (spec §8.17). Registered as a top-level command."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import AllFlag, JsonFlag, LimitOpt, gate, graph_command, page_bounds
8
+ from mgraphctl.graph import search as search_ops
9
+ from mgraphctl.http import GraphClient
10
+ from mgraphctl.render import (
11
+ ListResult,
12
+ Window,
13
+ parse_dt,
14
+ )
15
+
16
+
17
+ def _split_fields(value: str | None) -> list[str] | None:
18
+ if value is None:
19
+ return None
20
+ fields = [f.strip() for f in value.split(",") if f.strip()]
21
+ return fields or None
22
+
23
+
24
+ @graph_command(scopes=[]) # the scope depends on --type; gated at runtime below (spec §8.17)
25
+ def command(
26
+ client: GraphClient,
27
+ q: Annotated[str, typer.Argument(metavar="Q", help="What to search for.")],
28
+ type_: Annotated[
29
+ str,
30
+ typer.Option(
31
+ "--type", help="message, event, driveItem, site, list, chatMessage, or person."
32
+ ),
33
+ ] = "message",
34
+ after: Annotated[
35
+ str | None, typer.Option("--after", metavar="DT", help="Only hits after this time.")
36
+ ] = None,
37
+ before: Annotated[
38
+ str | None, typer.Option("--before", metavar="DT", help="Only hits before this time.")
39
+ ] = None,
40
+ limit: LimitOpt = None,
41
+ all_: AllFlag = False,
42
+ fields: Annotated[
43
+ str | None, typer.Option("--fields", help="Comma-separated Graph fields to fetch.")
44
+ ] = None,
45
+ json_: JsonFlag = False,
46
+ ):
47
+ """Search mail, calendar, files, sites, chats or people with the Microsoft Search API."""
48
+ entity_type = search_ops.normalise_entity_type(type_)
49
+ gate(search_ops.SCOPES_BY_TYPE[entity_type])
50
+ limit, all_ = page_bounds(limit, all_, default=search_ops.DEFAULT_LIMIT)
51
+ tz = client.tz
52
+ after_dt = parse_dt(after, tz) if after else None
53
+ before_dt = parse_dt(before, tz, end_of_day=True) if before else None
54
+ found = search_ops.search(
55
+ client,
56
+ entity_type=entity_type,
57
+ q=q,
58
+ after=after_dt,
59
+ before=before_dt,
60
+ limit=limit,
61
+ all_=all_,
62
+ fields=_split_fields(fields),
63
+ tz=tz,
64
+ )
65
+ return ListResult(
66
+ items=[search_ops.shape_hit(entity_type, hit) for hit in found.hits],
67
+ truncated=found.more,
68
+ fetched=found.fetched_count,
69
+ cap=found.cap,
70
+ query=found.query,
71
+ window=Window(after=after_dt, before=before_dt),
72
+ hit_cap=search_ops.CAP_SEARCH if all_ else None,
73
+ empty_text="No results.",
74
+ columns=search_ops.columns_for(entity_type, tz),
75
+ )