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,83 @@
1
+ """Graph operations for people, contacts, directory users and photos (spec §8.5).
2
+
3
+ Pure: client and parameters in, Graph dicts / PageResult / DownloadResult out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from mgraphctl import odata
11
+ from mgraphctl.errors import GraphError
12
+ from mgraphctl.http import DownloadResult, GraphClient, PageResult
13
+
14
+ PEOPLE_SELECT = (
15
+ "id,displayName,scoredEmailAddresses,jobTitle,department,companyName,personType,"
16
+ "userPrincipalName"
17
+ )
18
+ CONTACTS_SELECT = "id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName"
19
+
20
+ PAGE = 50
21
+ CAP = 250 # spec §5.3: people search, contacts — page_size 50, default limit 20, --all cap 250
22
+
23
+
24
+ def search_people(client: GraphClient, q: str, *, limit: int, all_: bool) -> PageResult:
25
+ """`/me/people` relevance search (spec §8.5 `search`); the endpoint is in maintenance mode."""
26
+ params = {"$search": odata.kql(q), "$select": PEOPLE_SELECT}
27
+ return client.paginate(
28
+ "/me/people", params=params, limit=limit, all_=all_, cap=CAP, page_size=PAGE
29
+ )
30
+
31
+
32
+ def _contact_matches(contact: dict, term: str) -> bool:
33
+ if term in (contact.get("displayName") or "").casefold():
34
+ return True
35
+ return any(
36
+ term in (addr.get("address") or "").casefold()
37
+ for addr in contact.get("emailAddresses") or []
38
+ )
39
+
40
+
41
+ def list_contacts(client: GraphClient, *, search: str | None, limit: int, all_: bool) -> PageResult:
42
+ """Contacts, optionally `$search`-filtered; a 400 on `$search` falls back to a client-side
43
+ substring match over displayName/email of up to 250 contacts (spec §8.5 `contacts`).
44
+ """
45
+ if not search:
46
+ return client.paginate(
47
+ "/me/contacts",
48
+ params={"$select": CONTACTS_SELECT},
49
+ limit=limit,
50
+ all_=all_,
51
+ cap=CAP,
52
+ page_size=PAGE,
53
+ )
54
+ params = {"$select": CONTACTS_SELECT, "$search": odata.kql(search)}
55
+ try:
56
+ return client.paginate(
57
+ "/me/contacts", params=params, limit=limit, all_=all_, cap=CAP, page_size=PAGE
58
+ )
59
+ except GraphError as exc:
60
+ if exc.status != 400:
61
+ raise
62
+ page = client.paginate(
63
+ "/me/contacts",
64
+ params={"$select": CONTACTS_SELECT},
65
+ limit=None,
66
+ all_=True,
67
+ cap=CAP,
68
+ page_size=CAP,
69
+ )
70
+ term = search.casefold()
71
+ filtered = [c for c in page.items if _contact_matches(c, term)]
72
+ bound = CAP if all_ else min(limit, CAP)
73
+ return PageResult(items=filtered[:bound], truncated=len(filtered) > bound, pages=page.pages)
74
+
75
+
76
+ def get_contact(client: GraphClient, contact_id: str) -> dict:
77
+ return client.get(odata.p("me", "contacts", contact_id))
78
+
79
+
80
+ def download_photo(client: GraphClient, upn: str | None, size: str, dest: Path) -> DownloadResult:
81
+ """`/me/photo/$value` for the signed-in user; `/users/{upn}/photos/{size}/$value` otherwise."""
82
+ path = "/me/photo/$value" if upn is None else f"{odata.p('users', upn)}/photos/{size}/$value"
83
+ return client.download(path, dest)
@@ -0,0 +1,415 @@
1
+ """Graph operations for Planner plans and tasks (spec §8.14).
2
+
3
+ Pure: client and parameters in, Graph dicts / `Plan` out. Planner paths carry no OData query
4
+ parameters (spec §8.14); slicing (`--limit`) and filtering (bucket, completed) happen client-side
5
+ on values already returned by Graph. `list_plans` and `my_tasks` annotate merged items with
6
+ underscore-prefixed keys (`_groupName`, `_planTitle`, `_bucketName`) used only by text-mode
7
+ columns; JSON callers get them too since `render.emit` serialises `ListResult.items` unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from mgraphctl import odata
16
+ from mgraphctl.errors import GraphError, MsgraphError, UsageError
17
+ from mgraphctl.graph import users
18
+ from mgraphctl.http import (
19
+ JSON_HEADERS,
20
+ BatchRequest,
21
+ GraphClient,
22
+ PageResult,
23
+ Plan,
24
+ PlannedRequest,
25
+ )
26
+ from mgraphctl.render import to_iso_offset
27
+ from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
28
+
29
+ # `on_skip`, when given, is called with one human-readable line per batch sub-request that
30
+ # failed (e.g. a group without Planner enabled, or a plan deleted since `my_tasks` read the
31
+ # task list). Both `list_plans` and `my_tasks` degrade gracefully on such a failure — the rest
32
+ # of the merged/annotated result is still returned — rather than failing the whole command;
33
+ # callers pass `render.note` so the omission is visible on stderr instead of silent. `graph/*`
34
+ # functions never call `render` directly (spec §7.2), hence the injected callback.
35
+ OnSkip = Callable[[str], None]
36
+
37
+ MY_TASKS_PLAN_TITLE_CAP = 20
38
+ PERCENT_VALUES = (0, 50, 100)
39
+ # Not a user-facing cap: large enough that name resolution never misses a real match because
40
+ # a display `--limit` truncated the merged list first.
41
+ RESOLUTION_LIMIT = 10_000
42
+
43
+
44
+ def _run(client: GraphClient, plan: Plan) -> Any:
45
+ """Execute every step of `plan` in order, returning the last step's result."""
46
+ result: Any = None
47
+ for step in plan:
48
+ result = client.execute(step)
49
+ return result
50
+
51
+
52
+ # --------------------------------------------------------------------------- reads
53
+
54
+
55
+ def list_plans(
56
+ client: GraphClient, *, limit: int = 50, on_skip: OnSkip | None = None
57
+ ) -> PageResult:
58
+ """Plans the signed-in user owns, plus every plan of a group they belong to (§8.14).
59
+
60
+ `GET /me/planner/plans` ∪ (for each unified group from `users.list_unified_groups`) a
61
+ `$batch` of `GET /groups/{id}/planner/plans`, deduped by id. Plans found only via a group
62
+ are annotated with `_groupName` (text column only); the owned list is authoritative and
63
+ keeps no `_groupName`, but a plan seen in more than one place still gets one if a group
64
+ supplied it. A group whose batch sub-request fails (e.g. Planner not enabled there) is
65
+ skipped rather than failing the whole call — same policy as `my_tasks` below for a failed
66
+ plan-title lookup — reported through `on_skip` when given.
67
+ """
68
+ own = client.get("/me/planner/plans") or {}
69
+ merged: dict[str, dict] = {item["id"]: item for item in own.get("value") or []}
70
+ groups = users.list_unified_groups(client)
71
+ if groups:
72
+ requests = [
73
+ BatchRequest(
74
+ id=str(i), method="GET", url=odata.p("groups", g["id"], "planner", "plans")
75
+ )
76
+ for i, g in enumerate(groups, 1)
77
+ ]
78
+ responses = client.batch(requests)
79
+ for group, response in zip(groups, responses, strict=True):
80
+ if response.error is not None:
81
+ if on_skip is not None:
82
+ name = group.get("displayName") or group["id"]
83
+ on_skip(f"skipped plans for group {name}: {response.error.message}")
84
+ continue
85
+ for item in (response.body or {}).get("value") or []:
86
+ existing = merged.get(item["id"])
87
+ if existing is not None:
88
+ existing.setdefault("_groupName", group.get("displayName"))
89
+ else:
90
+ merged[item["id"]] = {**item, "_groupName": group.get("displayName")}
91
+ items = list(merged.values())
92
+ return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
93
+
94
+
95
+ def resolve_plan(client: GraphClient, value: str) -> dict:
96
+ """A plan id, or `title` looked up among `list_plans` (§6.6)."""
97
+ if looks_like_id(value, "planner"):
98
+ bare, _ = split_id_prefix(value)
99
+ return {"id": bare}
100
+ # Resolution needs every visible plan, not just the first page a `--limit` would keep.
101
+ candidates = list_plans(client, limit=RESOLUTION_LIMIT)
102
+ return pick_unique(candidates.items, "title", value, what="plan")
103
+
104
+
105
+ def get_plan(client: GraphClient, plan_id: str) -> dict:
106
+ """`{...plan, "details": {...}}` (spec §8.14 `plan`)."""
107
+ plan_obj = client.get(odata.p("planner", "plans", plan_id))
108
+ details = client.get(odata.p("planner", "plans", plan_id, "details"))
109
+ return {**plan_obj, "details": details}
110
+
111
+
112
+ def list_buckets(client: GraphClient, plan_id: str) -> list[dict]:
113
+ payload = client.get(odata.p("planner", "plans", plan_id, "buckets")) or {}
114
+ return payload.get("value") or []
115
+
116
+
117
+ def resolve_bucket(client: GraphClient, plan_id: str, value: str) -> dict:
118
+ """A bucket id, or `name` looked up among the plan's buckets (§6.6)."""
119
+ if looks_like_id(value, "planner"):
120
+ bare, _ = split_id_prefix(value)
121
+ return {"id": bare}
122
+ return pick_unique(list_buckets(client, plan_id), "name", value, what="bucket")
123
+
124
+
125
+ def resolve_bucket_for_task(client: GraphClient, task_id: str, value: str) -> dict:
126
+ """Resolve a `--bucket` name on `update`, where the plan is not already known.
127
+
128
+ An id-shaped value never needs the task's plan; a name does, so this fetches the task
129
+ first to learn `planId` before delegating to `resolve_bucket`.
130
+ """
131
+ if looks_like_id(value, "planner"):
132
+ bare, _ = split_id_prefix(value)
133
+ return {"id": bare}
134
+ current = client.get(odata.p("planner", "tasks", task_id))
135
+ return resolve_bucket(client, current["planId"], value)
136
+
137
+
138
+ def list_tasks(
139
+ client: GraphClient,
140
+ plan_id: str,
141
+ *,
142
+ bucket_id: str | None = None,
143
+ include_completed: bool = False,
144
+ limit: int = 50,
145
+ ) -> PageResult:
146
+ """A plan's tasks, hiding completed ones and naming buckets, sliced to `limit` (§8.14)."""
147
+ payload = client.get(odata.p("planner", "plans", plan_id, "tasks")) or {}
148
+ items = payload.get("value") or []
149
+ if not include_completed:
150
+ items = [t for t in items if t.get("percentComplete") != 100]
151
+ if bucket_id is not None:
152
+ items = [t for t in items if t.get("bucketId") == bucket_id]
153
+ names = {b["id"]: b.get("name") for b in list_buckets(client, plan_id)}
154
+ for item in items:
155
+ item["_bucketName"] = names.get(item.get("bucketId"), "")
156
+ return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
157
+
158
+
159
+ def my_tasks(
160
+ client: GraphClient,
161
+ *,
162
+ include_completed: bool = False,
163
+ limit: int = 50,
164
+ on_skip: OnSkip | None = None,
165
+ ) -> PageResult:
166
+ """The signed-in user's tasks across every plan, annotated with plan titles (§8.14 `--my`).
167
+
168
+ A plan whose title lookup fails (e.g. deleted since the task was created) is skipped —
169
+ that task just keeps a blank `_planTitle` — same graceful-degradation policy as
170
+ `list_plans` above for a failed group lookup, reported through `on_skip` when given.
171
+ """
172
+ payload = client.get("/me/planner/tasks") or {}
173
+ items = payload.get("value") or []
174
+ if not include_completed:
175
+ items = [t for t in items if t.get("percentComplete") != 100]
176
+ plan_ids = list(dict.fromkeys(t["planId"] for t in items if t.get("planId")))
177
+ plan_ids = plan_ids[:MY_TASKS_PLAN_TITLE_CAP]
178
+ titles: dict[str, str] = {}
179
+ if plan_ids:
180
+ requests = [
181
+ BatchRequest(id=str(i), method="GET", url=odata.p("planner", "plans", pid))
182
+ for i, pid in enumerate(plan_ids, 1)
183
+ ]
184
+ responses = client.batch(requests)
185
+ for pid, response in zip(plan_ids, responses, strict=True):
186
+ if response.error is None and isinstance(response.body, dict):
187
+ titles[pid] = response.body.get("title", "")
188
+ elif on_skip is not None:
189
+ detail = response.error.message if response.error else "empty response"
190
+ on_skip(f"skipped plan title for {pid}: {detail}")
191
+ for item in items:
192
+ item["_planTitle"] = titles.get(item.get("planId"), "")
193
+ return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
194
+
195
+
196
+ def get_task(client: GraphClient, task_id: str) -> dict:
197
+ """`{...task, "details": {...}}`; a details fetch failure is swallowed (§8.14 `task`)."""
198
+ task_obj = client.get(odata.p("planner", "tasks", task_id))
199
+ try:
200
+ details = client.get(odata.p("planner", "tasks", task_id, "details"))
201
+ except MsgraphError:
202
+ details = {}
203
+ return {**task_obj, "details": details}
204
+
205
+
206
+ # --------------------------------------------------------------------------- writes
207
+
208
+
209
+ def _create_body(
210
+ *,
211
+ plan_id: str,
212
+ bucket_id: str | None,
213
+ title: str,
214
+ due: Any,
215
+ assignee_oids: list[str],
216
+ priority: int | None,
217
+ ) -> dict:
218
+ body: dict[str, Any] = {"planId": plan_id}
219
+ if bucket_id is not None:
220
+ body["bucketId"] = bucket_id
221
+ body["title"] = title
222
+ if due is not None:
223
+ body["dueDateTime"] = to_iso_offset(due)
224
+ if priority is not None:
225
+ body["priority"] = priority
226
+ if assignee_oids:
227
+ body["assignments"] = {
228
+ oid: {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
229
+ for oid in assignee_oids
230
+ }
231
+ return body
232
+
233
+
234
+ def _description_step(tasks_url: str, task_id: str, description: str) -> PlannedRequest:
235
+ url = f"{tasks_url}/{task_id}/details"
236
+ return PlannedRequest(
237
+ "PATCH", url, {**JSON_HEADERS, "If-Match": "{etag}"}, {"description": description}
238
+ )
239
+
240
+
241
+ def _write_description(client: GraphClient, task_id: str, description: str) -> dict:
242
+ path = odata.p("planner", "tasks", task_id, "details")
243
+ details = client.get(path) or {}
244
+ etag = details.get("@odata.etag")
245
+ headers = {"If-Match": etag} if etag else {}
246
+ return client.patch(path, json={"description": description}, headers=headers)
247
+
248
+
249
+ def plan_create_task(
250
+ client: GraphClient,
251
+ *,
252
+ plan_id: str,
253
+ bucket_id: str | None = None,
254
+ title: str,
255
+ due: Any = None,
256
+ assignee_oids: list[str] | None = None,
257
+ priority: int | None = None,
258
+ description: str | None = None,
259
+ ) -> Plan:
260
+ body = _create_body(
261
+ plan_id=plan_id,
262
+ bucket_id=bucket_id,
263
+ title=title,
264
+ due=due,
265
+ assignee_oids=assignee_oids or [],
266
+ priority=priority,
267
+ )
268
+ tasks_url = client.url(odata.p("planner", "tasks"))
269
+ steps: Plan = [PlannedRequest("POST", tasks_url, dict(JSON_HEADERS), body)]
270
+ if description is not None:
271
+ steps.append(_description_step(tasks_url, "{taskId}", description))
272
+ return steps
273
+
274
+
275
+ def run_create_task(
276
+ client: GraphClient,
277
+ *,
278
+ plan_id: str,
279
+ bucket_id: str | None = None,
280
+ title: str,
281
+ due: Any = None,
282
+ assignee_oids: list[str] | None = None,
283
+ priority: int | None = None,
284
+ description: str | None = None,
285
+ ) -> dict:
286
+ body = _create_body(
287
+ plan_id=plan_id,
288
+ bucket_id=bucket_id,
289
+ title=title,
290
+ due=due,
291
+ assignee_oids=assignee_oids or [],
292
+ priority=priority,
293
+ )
294
+ task = client.post(odata.p("planner", "tasks"), json=body)
295
+ if description is not None:
296
+ details = _write_description(client, task["id"], description)
297
+ task = {**task, "details": details}
298
+ return task
299
+
300
+
301
+ def _update_body(
302
+ *,
303
+ title: str | None = None,
304
+ due: Any = None,
305
+ percent: int | None = None,
306
+ bucket_id: str | None = None,
307
+ priority: int | None = None,
308
+ assign_oids: list[str] | None = None,
309
+ unassign_oids: list[str] | None = None,
310
+ ) -> dict:
311
+ body: dict[str, Any] = {}
312
+ if title is not None:
313
+ body["title"] = title
314
+ if due is not None:
315
+ body["dueDateTime"] = to_iso_offset(due)
316
+ if percent is not None:
317
+ if percent not in PERCENT_VALUES:
318
+ raise UsageError("USAGE", "--percent must be 0, 50 or 100")
319
+ body["percentComplete"] = percent
320
+ if bucket_id is not None:
321
+ body["bucketId"] = bucket_id
322
+ if priority is not None:
323
+ body["priority"] = priority
324
+ assignments: dict[str, Any] = {}
325
+ for oid in assign_oids or []:
326
+ assignments[oid] = {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
327
+ for oid in unassign_oids or []:
328
+ assignments[oid] = None
329
+ if assignments:
330
+ body["assignments"] = assignments
331
+ return body
332
+
333
+
334
+ def plan_update_task(
335
+ client: GraphClient,
336
+ task_id: str,
337
+ *,
338
+ title: str | None = None,
339
+ due: Any = None,
340
+ percent: int | None = None,
341
+ bucket_id: str | None = None,
342
+ priority: int | None = None,
343
+ assign_oids: list[str] | None = None,
344
+ unassign_oids: list[str] | None = None,
345
+ description: str | None = None,
346
+ ) -> Plan:
347
+ body = _update_body(
348
+ title=title,
349
+ due=due,
350
+ percent=percent,
351
+ bucket_id=bucket_id,
352
+ priority=priority,
353
+ assign_oids=assign_oids,
354
+ unassign_oids=unassign_oids,
355
+ )
356
+ tasks_url = client.url(odata.p("planner", "tasks"))
357
+ steps: Plan = []
358
+ if body:
359
+ headers = {**JSON_HEADERS, "If-Match": "{etag}", "Prefer": "return=representation"}
360
+ steps.append(PlannedRequest("PATCH", f"{tasks_url}/{task_id}", headers, body))
361
+ if description is not None:
362
+ steps.append(_description_step(tasks_url, task_id, description))
363
+ return steps
364
+
365
+
366
+ def run_update_task(
367
+ client: GraphClient,
368
+ task_id: str,
369
+ *,
370
+ title: str | None = None,
371
+ due: Any = None,
372
+ percent: int | None = None,
373
+ bucket_id: str | None = None,
374
+ priority: int | None = None,
375
+ assign_oids: list[str] | None = None,
376
+ unassign_oids: list[str] | None = None,
377
+ description: str | None = None,
378
+ ) -> dict:
379
+ body = _update_body(
380
+ title=title,
381
+ due=due,
382
+ percent=percent,
383
+ bucket_id=bucket_id,
384
+ priority=priority,
385
+ assign_oids=assign_oids,
386
+ unassign_oids=unassign_oids,
387
+ )
388
+ result: dict | None = None
389
+ if body:
390
+ path = odata.p("planner", "tasks", task_id)
391
+ etag = (client.get(path) or {}).get("@odata.etag")
392
+ headers = {"If-Match": etag, "Prefer": "return=representation"}
393
+ try:
394
+ result = client.patch(path, json=body, headers=headers)
395
+ except GraphError as exc:
396
+ if exc.status != 412:
397
+ raise
398
+ etag = (client.get(path) or {}).get("@odata.etag")
399
+ headers = {"If-Match": etag, "Prefer": "return=representation"}
400
+ result = client.patch(path, json=body, headers=headers)
401
+ if description is not None:
402
+ details = _write_description(client, task_id, description)
403
+ result = {**(result or {}), "details": details}
404
+ return result or {}
405
+
406
+
407
+ def plan_delete_task(client: GraphClient, task_id: str) -> Plan:
408
+ url = client.url(odata.p("planner", "tasks", task_id))
409
+ return [PlannedRequest("DELETE", url, {"If-Match": "{etag}"}, None, expect="none")]
410
+
411
+
412
+ def run_delete_task(client: GraphClient, task_id: str) -> None:
413
+ path = odata.p("planner", "tasks", task_id)
414
+ etag = (client.get(path) or {}).get("@odata.etag")
415
+ client.delete(path, headers={"If-Match": etag}, expect="none")
@@ -0,0 +1,89 @@
1
+ """Graph operations for Teams presence (spec §8.9).
2
+
3
+ Pure: client and parameters in, Graph dicts or a `Plan` out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterable
9
+ from datetime import timedelta
10
+
11
+ from mgraphctl import odata
12
+ from mgraphctl.errors import UsageError
13
+ from mgraphctl.http import JSON_HEADERS, GraphClient, Plan, PlannedRequest
14
+ from mgraphctl.render import iso_duration
15
+
16
+ # The CLI state, and the `availability` / `activity` pair Graph wants for it.
17
+ PRESENCE_PAIRS: dict[str, tuple[str, str]] = {
18
+ "available": ("Available", "Available"),
19
+ "busy": ("Busy", "Busy"),
20
+ "dnd": ("DoNotDisturb", "DoNotDisturb"),
21
+ "brb": ("BeRightBack", "BeRightBack"),
22
+ "away": ("Away", "Away"),
23
+ "offline": ("Offline", "OffWork"),
24
+ }
25
+
26
+
27
+ def resolve_pair(state: str) -> tuple[str, str]:
28
+ """The Graph availability/activity pair for a CLI state name."""
29
+ pair = PRESENCE_PAIRS.get(state.casefold())
30
+ if pair is None:
31
+ raise UsageError(
32
+ "USAGE", f"unknown presence {state!r}; use one of {', '.join(PRESENCE_PAIRS)}"
33
+ )
34
+ return pair
35
+
36
+
37
+ def get_presence(client: GraphClient) -> dict:
38
+ return client.get("/me/presence")
39
+
40
+
41
+ def get_presences(client: GraphClient, ids: Iterable[str]) -> list[dict]:
42
+ payload = client.post("/communications/getPresencesByUserId", json={"ids": list(ids)}) or {}
43
+ return payload.get("value") or []
44
+
45
+
46
+ def _presence_base(client: GraphClient, my_oid: str) -> str:
47
+ """`/users/{oid}/presence`; the oid comes from the token, so there is no `/me` call."""
48
+ return client.url(odata.p("users", my_oid, "presence"))
49
+
50
+
51
+ def plan_set(
52
+ client: GraphClient,
53
+ my_oid: str,
54
+ availability: str,
55
+ *,
56
+ expiration: timedelta,
57
+ message: str | None,
58
+ ) -> Plan:
59
+ state, activity = resolve_pair(availability)
60
+ base = _presence_base(client, my_oid)
61
+ steps = [
62
+ PlannedRequest(
63
+ "POST",
64
+ f"{base}/setUserPreferredPresence",
65
+ dict(JSON_HEADERS),
66
+ {
67
+ "availability": state,
68
+ "activity": activity,
69
+ "expirationDuration": iso_duration(expiration),
70
+ },
71
+ expect="none",
72
+ )
73
+ ]
74
+ if message is not None:
75
+ steps.append(
76
+ PlannedRequest(
77
+ "POST",
78
+ f"{base}/setStatusMessage",
79
+ dict(JSON_HEADERS),
80
+ {"statusMessage": {"message": {"content": message, "contentType": "text"}}},
81
+ expect="none",
82
+ )
83
+ )
84
+ return steps
85
+
86
+
87
+ def plan_clear(client: GraphClient, my_oid: str) -> Plan:
88
+ base = _presence_base(client, my_oid)
89
+ return [PlannedRequest("POST", f"{base}/clearUserPreferredPresence", {}, None, expect="none")]