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,351 @@
1
+ """OneDrive files: browse, search, up/download, organise (spec §8.11).
2
+
3
+ Every verb targets the signed-in user's own drive unless `--drive DRIVE_ID` names another one
4
+ (`shared-with-me`, `recent` and `link` are always the caller's own drive/shares, so they have no
5
+ `--drive` option). Item and folder metadata operations delegate to `mgraphctl.graph.files`.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Annotated, Literal
10
+
11
+ import typer
12
+
13
+ from mgraphctl.cli import (
14
+ AllFlag,
15
+ DryRunFlag,
16
+ JsonFlag,
17
+ LimitOpt,
18
+ graph_command,
19
+ make_noun_app,
20
+ page_bounds,
21
+ )
22
+ from mgraphctl.graph import files, onedrive
23
+ from mgraphctl.http import GraphClient
24
+ from mgraphctl.render import (
25
+ Column,
26
+ DryRunResult,
27
+ FileResult,
28
+ ListResult,
29
+ ObjectResult,
30
+ WriteResult,
31
+ dig,
32
+ fmt_dt,
33
+ fmt_size,
34
+ note,
35
+ parse_dt,
36
+ )
37
+
38
+ app = make_noun_app("OneDrive files.")
39
+
40
+ DriveOpt = Annotated[str | None, typer.Option("--drive", help="Target another drive by id.")]
41
+ RefArg = Annotated[str, typer.Argument(metavar="ID|PATH", help="Item id, 'id:ID', or a path.")]
42
+
43
+ SHARED_WITH_ME_NOTE = (
44
+ "note: /me/drive/sharedWithMe is deprecated by Microsoft and returns nothing after "
45
+ "November 2026"
46
+ )
47
+
48
+
49
+ def _item_fields(tz: str) -> list[tuple[str, object]]:
50
+ """`Label : value` fields for `get`/`link` (an `ObjectResult` view of one drive item)."""
51
+ return [
52
+ ("Name", "name"),
53
+ ("Id", "id"),
54
+ ("Type", lambda i: "folder" if "folder" in i else "file"),
55
+ ("Size", lambda i: fmt_size(i.get("size"))),
56
+ ("Modified", lambda i: fmt_dt(i.get("lastModifiedDateTime"), tz)),
57
+ ("Web URL", "webUrl"),
58
+ ("Parent", lambda i: dig(i, "parentReference.path")),
59
+ ]
60
+
61
+
62
+ # --------------------------------------------------------------------------- browsing
63
+
64
+
65
+ @app.command("ls")
66
+ @graph_command(scopes=["Files.Read"])
67
+ def ls(
68
+ client: GraphClient,
69
+ path: Annotated[str | None, typer.Argument(metavar="PATH")] = None,
70
+ drive: DriveOpt = None,
71
+ limit: LimitOpt = None,
72
+ all_: AllFlag = False,
73
+ json_: JsonFlag = False,
74
+ ):
75
+ """List the files and folders in a drive (default: the drive root)."""
76
+ limit, all_ = page_bounds(limit, all_, default=50)
77
+ base = onedrive.base_for(drive)
78
+ page = files.list_children(client, base, path, limit=limit, all_=all_)
79
+ return ListResult(
80
+ items=page.items,
81
+ page=page,
82
+ hit_cap=files.CAP_LS if all_ else None,
83
+ columns=files.item_columns(client.tz),
84
+ )
85
+
86
+
87
+ @app.command("search")
88
+ @graph_command(scopes=["Files.Read"])
89
+ def search(
90
+ client: GraphClient,
91
+ q: Annotated[str, typer.Argument(metavar="Q")],
92
+ drive: DriveOpt = None,
93
+ shared: Annotated[
94
+ bool, typer.Option("--shared", help="Search everything shared with you too.")
95
+ ] = False,
96
+ limit: LimitOpt = None,
97
+ json_: JsonFlag = False,
98
+ ):
99
+ """Search files and folders by name or content."""
100
+ base = onedrive.base_for(drive)
101
+ bound = limit if limit is not None else 50
102
+ page = files.search_items(client, base, q, limit=bound, shared=shared)
103
+ return ListResult(
104
+ items=page.items,
105
+ page=page,
106
+ supports_all=False,
107
+ columns=files.item_columns(client.tz),
108
+ )
109
+
110
+
111
+ @app.command("get")
112
+ @graph_command(scopes=["Files.Read"])
113
+ def get(
114
+ client: GraphClient,
115
+ ref: RefArg,
116
+ drive: DriveOpt = None,
117
+ json_: JsonFlag = False,
118
+ ):
119
+ """Show one file or folder's metadata."""
120
+ base = onedrive.base_for(drive)
121
+ item = files.get_item(client, base, ref)
122
+ return ObjectResult(obj=item, fields=_item_fields(client.tz))
123
+
124
+
125
+ @app.command("download")
126
+ @graph_command(scopes=["Files.Read"])
127
+ def download(
128
+ client: GraphClient,
129
+ ref: RefArg,
130
+ drive: DriveOpt = None,
131
+ output: Annotated[
132
+ Path | None, typer.Option("--output", help="Destination file (default: the item name).")
133
+ ] = None,
134
+ json_: JsonFlag = False,
135
+ ):
136
+ """Download a file's contents."""
137
+ base = onedrive.base_for(drive)
138
+ item, got = files.download_item(client, base, ref, output)
139
+ return FileResult(
140
+ path=got.path,
141
+ bytes=got.bytes,
142
+ meta=dict(contentType=got.content_type),
143
+ message=f"Downloaded {item['name']} ({fmt_size(got.bytes)}) to {got.path}",
144
+ )
145
+
146
+
147
+ # --------------------------------------------------------------------------- writes
148
+
149
+
150
+ @app.command("upload")
151
+ @graph_command(scopes=["Files.ReadWrite"])
152
+ def upload(
153
+ client: GraphClient,
154
+ file: Annotated[
155
+ Path,
156
+ typer.Argument(metavar="FILE", help="Local file to upload.", exists=True, dir_okay=False),
157
+ ],
158
+ drive: DriveOpt = None,
159
+ dest: Annotated[
160
+ str | None,
161
+ typer.Option(
162
+ "--dest", help="Destination path (default: /<basename>; trailing / = folder)."
163
+ ),
164
+ ] = None,
165
+ conflict: Annotated[
166
+ Literal["rename", "replace", "fail"], typer.Option("--conflict", help="On a name clash.")
167
+ ] = "replace",
168
+ dry_run: DryRunFlag = False,
169
+ json_: JsonFlag = False,
170
+ ):
171
+ """Upload a local file (simple PUT under 4 MiB, an upload session above it)."""
172
+ base = onedrive.base_for(drive)
173
+ if dry_run:
174
+ return DryRunResult(files.plan_upload(client, base, file, dest, conflict))
175
+ item = files.run_upload(client, base, file, dest, conflict)
176
+ return WriteResult(obj=item, message=f"Uploaded {file.name}.")
177
+
178
+
179
+ @app.command("mkdir")
180
+ @graph_command(scopes=["Files.ReadWrite"])
181
+ def mkdir(
182
+ client: GraphClient,
183
+ path: Annotated[str, typer.Argument(metavar="PATH")],
184
+ drive: DriveOpt = None,
185
+ dry_run: DryRunFlag = False,
186
+ json_: JsonFlag = False,
187
+ ):
188
+ """Create a folder."""
189
+ base = onedrive.base_for(drive)
190
+ if dry_run:
191
+ return DryRunResult(files.plan_mkdir(client, base, path))
192
+ item = files.run_mkdir(client, base, path)
193
+ return WriteResult(obj=item, message=f"Created {item.get('name', path)}.")
194
+
195
+
196
+ @app.command("move")
197
+ @graph_command(scopes=["Files.ReadWrite"])
198
+ def move(
199
+ client: GraphClient,
200
+ ref: RefArg,
201
+ to: Annotated[str, typer.Option("--to", help="Destination folder path, or 'id:ID'.")] = ...,
202
+ drive: DriveOpt = None,
203
+ name: Annotated[str | None, typer.Option("--name", help="Rename while moving.")] = None,
204
+ dry_run: DryRunFlag = False,
205
+ json_: JsonFlag = False,
206
+ ):
207
+ """Move (and optionally rename) a file or folder."""
208
+ base = onedrive.base_for(drive)
209
+ if dry_run:
210
+ return DryRunResult(files.plan_move(client, base, ref, to, name))
211
+ item = files.run_move(client, base, ref, to, name)
212
+ return WriteResult(obj=item, message=f"Moved {ref} to {to}.")
213
+
214
+
215
+ @app.command("rename")
216
+ @graph_command(scopes=["Files.ReadWrite"])
217
+ def rename(
218
+ client: GraphClient,
219
+ ref: RefArg,
220
+ name: Annotated[str, typer.Argument(metavar="NAME")],
221
+ drive: DriveOpt = None,
222
+ dry_run: DryRunFlag = False,
223
+ json_: JsonFlag = False,
224
+ ):
225
+ """Rename a file or folder."""
226
+ base = onedrive.base_for(drive)
227
+ if dry_run:
228
+ return DryRunResult(files.plan_rename(client, base, ref, name))
229
+ item = files.run_rename(client, base, ref, name)
230
+ return WriteResult(obj=item, message=f"Renamed to {name}.")
231
+
232
+
233
+ @app.command("delete")
234
+ @graph_command(scopes=["Files.ReadWrite"])
235
+ def delete(
236
+ client: GraphClient,
237
+ ref: RefArg,
238
+ drive: DriveOpt = None,
239
+ dry_run: DryRunFlag = False,
240
+ json_: JsonFlag = False,
241
+ ):
242
+ """Delete a file or folder (moves it to the recycle bin)."""
243
+ base = onedrive.base_for(drive)
244
+ if dry_run:
245
+ return DryRunResult(files.plan_delete(client, base, ref))
246
+ item_id = files.resolve_item_id(client, base, ref)
247
+ files.run_delete(client, base, ref)
248
+ return WriteResult(obj={"status": "deleted", "id": item_id}, message=f"Deleted {item_id}.")
249
+
250
+
251
+ @app.command("share")
252
+ @graph_command(scopes=["Files.ReadWrite"])
253
+ def share(
254
+ client: GraphClient,
255
+ ref: RefArg,
256
+ drive: DriveOpt = None,
257
+ link_type: Annotated[
258
+ Literal["view", "edit"], typer.Option("--type", help="Kind of link to create.")
259
+ ] = "view",
260
+ scope: Annotated[
261
+ Literal["organization", "anonymous"], typer.Option("--scope", help="Who can use it.")
262
+ ] = "organization",
263
+ expires: Annotated[
264
+ str | None, typer.Option("--expires", help="When the link stops working.")
265
+ ] = None,
266
+ dry_run: DryRunFlag = False,
267
+ json_: JsonFlag = False,
268
+ ):
269
+ """Create a sharing link."""
270
+ base = onedrive.base_for(drive)
271
+ expires_dt = parse_dt(expires, client.tz) if expires else None
272
+ if dry_run:
273
+ return DryRunResult(
274
+ files.plan_share(
275
+ client, base, ref, link_type=link_type, scope=scope, expires=expires_dt
276
+ )
277
+ )
278
+ link = files.run_share(client, base, ref, link_type=link_type, scope=scope, expires=expires_dt)
279
+ return WriteResult(obj=link, message=dig(link, "link.webUrl") or "")
280
+
281
+
282
+ # --------------------------------------------------------------------------- own-drive listings
283
+
284
+
285
+ @app.command("shared-with-me")
286
+ @graph_command(scopes=["Files.Read.All|Sites.Read.All"])
287
+ def shared_with_me(
288
+ client: GraphClient,
289
+ limit: LimitOpt = None,
290
+ json_: JsonFlag = False,
291
+ ):
292
+ """List items other people have shared with you."""
293
+ note(SHARED_WITH_ME_NOTE)
294
+ bound = limit if limit is not None else 50
295
+ page = onedrive.shared_with_me(client, limit=bound)
296
+ tz = client.tz
297
+ columns = [
298
+ Column("id", "id"),
299
+ Column("name", "name"),
300
+ Column("size", lambda i: fmt_size(i.get("size"))),
301
+ Column("modified", lambda i: fmt_dt(i.get("lastModifiedDateTime"), tz)),
302
+ Column("sharedDriveId", lambda i: dig(i, "remoteItem.parentReference.driveId") or ""),
303
+ Column("sharedItemId", lambda i: dig(i, "remoteItem.id") or ""),
304
+ ]
305
+ return ListResult(items=page.items, page=page, supports_all=False, columns=columns)
306
+
307
+
308
+ @app.command("recent")
309
+ @graph_command(scopes=["Files.Read"])
310
+ def recent(
311
+ client: GraphClient,
312
+ limit: LimitOpt = None,
313
+ json_: JsonFlag = False,
314
+ ):
315
+ """List recently used files."""
316
+ bound = limit if limit is not None else 20
317
+ page = onedrive.recent(client, limit=bound)
318
+ return ListResult(
319
+ items=page.items,
320
+ page=page,
321
+ supports_all=False,
322
+ columns=files.item_columns(client.tz),
323
+ )
324
+
325
+
326
+ @app.command("link")
327
+ @graph_command(scopes=["Files.Read"])
328
+ def link(
329
+ client: GraphClient,
330
+ url: Annotated[
331
+ str, typer.Argument(metavar="URL", help="A OneDrive or SharePoint sharing link.")
332
+ ],
333
+ download: Annotated[
334
+ bool, typer.Option("--download", help="Download the item's content.")
335
+ ] = False,
336
+ output: Annotated[
337
+ Path | None, typer.Option("--output", help="Destination file for --download.")
338
+ ] = None,
339
+ json_: JsonFlag = False,
340
+ ):
341
+ """Resolve a sharing link to the item it points at."""
342
+ if download:
343
+ item, got = files.download_shared_item(client, url, output)
344
+ return FileResult(
345
+ path=got.path,
346
+ bytes=got.bytes,
347
+ meta=dict(contentType=got.content_type),
348
+ message=f"Downloaded {item['name']} ({fmt_size(got.bytes)}) to {got.path}",
349
+ )
350
+ item = files.get_shared_item(client, url)
351
+ return ObjectResult(obj=item, fields=_item_fields(client.tz))
@@ -0,0 +1,196 @@
1
+ """OneNote notebooks, sections and pages (spec §8.13)."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from mgraphctl.cli import (
9
+ AllFlag,
10
+ DryRunFlag,
11
+ JsonFlag,
12
+ LimitOpt,
13
+ graph_command,
14
+ make_noun_app,
15
+ page_bounds,
16
+ read_body,
17
+ )
18
+ from mgraphctl.graph import onenote
19
+ from mgraphctl.html import to_markdown
20
+ from mgraphctl.http import GraphClient
21
+ from mgraphctl.render import (
22
+ Column,
23
+ DryRunResult,
24
+ FileResult,
25
+ ListResult,
26
+ TextResult,
27
+ WriteResult,
28
+ fmt_dt,
29
+ fmt_size,
30
+ truncate,
31
+ )
32
+
33
+ DEFAULT_LIMIT = 50
34
+
35
+ app = make_noun_app("OneNote notebooks and pages.")
36
+
37
+
38
+ @app.command("notebooks")
39
+ @graph_command(scopes=["Notes.Read"])
40
+ def notebooks(
41
+ client: GraphClient,
42
+ limit: LimitOpt = None,
43
+ json_: JsonFlag = False,
44
+ ):
45
+ """List OneNote notebooks."""
46
+ bound = limit if limit is not None else DEFAULT_LIMIT
47
+ page = onenote.list_notebooks(client, limit=bound)
48
+ tz = client.tz
49
+ return ListResult(
50
+ items=page.items,
51
+ page=page,
52
+ supports_all=False,
53
+ columns=[
54
+ Column("id", "id"),
55
+ Column("name", "displayName"),
56
+ Column("modified", lambda n: fmt_dt(n.get("lastModifiedDateTime"), tz)),
57
+ ],
58
+ )
59
+
60
+
61
+ @app.command("sections")
62
+ @graph_command(scopes=["Notes.Read"])
63
+ def sections(
64
+ client: GraphClient,
65
+ notebook: Annotated[str | None, typer.Argument(metavar="NOTEBOOK")] = None,
66
+ limit: LimitOpt = None,
67
+ json_: JsonFlag = False,
68
+ ):
69
+ """List OneNote sections, across all notebooks or within one."""
70
+ bound = limit if limit is not None else DEFAULT_LIMIT
71
+ notebook_id = onenote.resolve_notebook(client, notebook) if notebook is not None else None
72
+ page = onenote.list_sections(client, notebook_id, limit=bound)
73
+ tz = client.tz
74
+ return ListResult(
75
+ items=page.items,
76
+ page=page,
77
+ supports_all=False,
78
+ columns=[
79
+ Column("id", "id"),
80
+ Column("name", "displayName"),
81
+ Column("modified", lambda s: fmt_dt(s.get("lastModifiedDateTime"), tz)),
82
+ ],
83
+ )
84
+
85
+
86
+ @app.command("pages")
87
+ @graph_command(scopes=["Notes.Read"])
88
+ def pages(
89
+ client: GraphClient,
90
+ section: Annotated[str, typer.Argument(metavar="SECTION")],
91
+ limit: LimitOpt = None,
92
+ all_: AllFlag = False,
93
+ json_: JsonFlag = False,
94
+ ):
95
+ """List the pages in a section."""
96
+ bound, all_pages = page_bounds(limit, all_, default=DEFAULT_LIMIT)
97
+ section_id = onenote.resolve_section(client, section)
98
+ page = onenote.list_pages(client, section_id, limit=bound, all_=all_pages)
99
+ tz = client.tz
100
+ return ListResult(
101
+ items=page.items,
102
+ page=page,
103
+ hit_cap=onenote.CAP_LIST if all_pages else None,
104
+ columns=[
105
+ Column("id", "id"),
106
+ Column("modified", lambda pg: fmt_dt(pg.get("lastModifiedDateTime"), tz)),
107
+ Column("title", lambda pg: truncate(pg.get("title"))),
108
+ ],
109
+ )
110
+
111
+
112
+ @app.command("read")
113
+ @graph_command(scopes=["Notes.Read"])
114
+ def read(
115
+ client: GraphClient,
116
+ page: Annotated[str, typer.Argument(metavar="PAGE")],
117
+ html_: Annotated[bool, typer.Option("--html", help="Print the page's raw HTML.")] = False,
118
+ output: Annotated[
119
+ Path | None, typer.Option("--output", help="Write to this file instead of stdout.")
120
+ ] = None,
121
+ json_: JsonFlag = False,
122
+ ):
123
+ """Read a OneNote page as Markdown (or raw HTML with --html)."""
124
+ page_id = onenote.resolve_page(client, page)
125
+ meta, html_body = onenote.get_page(client, page_id)
126
+ markdown = to_markdown(html_body, mode="onenote")
127
+ doc = {
128
+ "id": meta.get("id"),
129
+ "title": meta.get("title"),
130
+ "html": html_body,
131
+ "markdown": markdown,
132
+ }
133
+ if output is not None:
134
+ content = html_body if html_ else markdown
135
+ output.parent.mkdir(parents=True, exist_ok=True)
136
+ data = content.encode("utf-8")
137
+ output.write_bytes(data)
138
+ return FileResult(
139
+ path=output,
140
+ bytes=len(data),
141
+ meta=dict(id=doc["id"], title=doc["title"]),
142
+ message=f"Wrote {fmt_size(len(data))} to {output}",
143
+ )
144
+ if html_:
145
+ return TextResult(text=html_body, json_obj=doc)
146
+ return TextResult(text=markdown, json_obj=doc)
147
+
148
+
149
+ @app.command("create")
150
+ @graph_command(scopes=["Notes.ReadWrite"])
151
+ def create(
152
+ client: GraphClient,
153
+ section: Annotated[str, typer.Option("--section", help="Section name or id.")],
154
+ title: Annotated[str, typer.Option("--title", help="Page title.")],
155
+ body: Annotated[str | None, typer.Option("--body", help="Page body text.")] = None,
156
+ body_file: Annotated[
157
+ str | None, typer.Option("--body-file", help="File, or - for stdin.")
158
+ ] = None,
159
+ html_: Annotated[
160
+ bool, typer.Option("--html", help="Treat --body/--body-file as raw HTML, unescaped.")
161
+ ] = False,
162
+ dry_run: DryRunFlag = False,
163
+ json_: JsonFlag = False,
164
+ ):
165
+ """Create a page in a OneNote section."""
166
+ text = read_body(body, body_file)
167
+ section_id = onenote.resolve_section(client, section)
168
+ plan = onenote.plan_create_page(client, section_id, title=title, body=text, html=html_)
169
+ if dry_run:
170
+ return DryRunResult(plan)
171
+ obj = client.execute(plan[0])
172
+ return WriteResult(obj=obj, message=f"Created page {title!r}.")
173
+
174
+
175
+ @app.command("search")
176
+ @graph_command(scopes=["Notes.Read"])
177
+ def search(
178
+ client: GraphClient,
179
+ q: Annotated[str, typer.Argument(metavar="Q")],
180
+ limit: LimitOpt = None,
181
+ json_: JsonFlag = False,
182
+ ):
183
+ """Search OneNote page titles and content (Graph supports this for consumer notebooks only)."""
184
+ bound = limit if limit is not None else DEFAULT_LIMIT
185
+ page = onenote.search_pages(client, q, limit=bound)
186
+ tz = client.tz
187
+ return ListResult(
188
+ items=page.items,
189
+ page=page,
190
+ supports_all=False,
191
+ columns=[
192
+ Column("id", "id"),
193
+ Column("created", lambda pg: fmt_dt(pg.get("createdDateTime"), tz)),
194
+ Column("title", lambda pg: truncate(pg.get("title"))),
195
+ ],
196
+ )
@@ -0,0 +1,102 @@
1
+ """Manager, direct reports and the management chain (spec §8.6)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import (
8
+ AllFlag,
9
+ JsonFlag,
10
+ LimitOpt,
11
+ gate,
12
+ graph_command,
13
+ make_noun_app,
14
+ page_bounds,
15
+ )
16
+ from mgraphctl.errors import GraphError
17
+ from mgraphctl.graph import org
18
+ from mgraphctl.http import GraphClient
19
+ from mgraphctl.render import Column, ListResult, ObjectResult, TextResult
20
+
21
+ app = make_noun_app("Manager, direct reports, management chain.")
22
+
23
+ MANAGER_FIELDS = [
24
+ ("Name", "displayName"),
25
+ ("UPN", "userPrincipalName"),
26
+ ("Mail", "mail"),
27
+ ("Title", "jobTitle"),
28
+ ("Dept", "department"),
29
+ ("User id", "id"),
30
+ ]
31
+
32
+
33
+ def _chain_line(level: int, person: dict) -> str:
34
+ upn = person.get("userPrincipalName") or ""
35
+ title = person.get("jobTitle")
36
+ suffix = f" — {title}" if title else ""
37
+ return f"{level}: {person.get('displayName')} <{upn}>{suffix}"
38
+
39
+
40
+ @app.command("manager")
41
+ @graph_command(scopes=["User.Read"])
42
+ def manager(
43
+ client: GraphClient,
44
+ upn: Annotated[str | None, typer.Argument(metavar="UPN")] = None,
45
+ json_: JsonFlag = False,
46
+ ):
47
+ """Show a user's manager (self by default)."""
48
+ if upn is not None:
49
+ gate(["User.Read.All"])
50
+ return ObjectResult(obj=org.manager(client, upn), fields=list(MANAGER_FIELDS))
51
+
52
+
53
+ @app.command("reports")
54
+ @graph_command(scopes=["User.Read"])
55
+ def reports(
56
+ client: GraphClient,
57
+ upn: Annotated[str | None, typer.Argument(metavar="UPN")] = None,
58
+ limit: LimitOpt = None,
59
+ all_: AllFlag = False,
60
+ json_: JsonFlag = False,
61
+ ):
62
+ """List a user's direct reports (self by default)."""
63
+ if upn is not None:
64
+ gate(["User.Read.All"])
65
+ limit, all_ = page_bounds(limit, all_, default=20)
66
+ page = org.reports(client, upn, limit=limit, all_=all_)
67
+ return ListResult(
68
+ items=page.items,
69
+ page=page,
70
+ hit_cap=org.CAP if all_ else None,
71
+ columns=[
72
+ Column("id", "id"),
73
+ Column("name", "displayName"),
74
+ Column("upn", "userPrincipalName"),
75
+ Column("mail", "mail"),
76
+ Column("title", "jobTitle"),
77
+ Column("department", "department"),
78
+ ],
79
+ )
80
+
81
+
82
+ @app.command("chain")
83
+ @graph_command(scopes=["User.Read"])
84
+ def chain(
85
+ client: GraphClient,
86
+ upn: Annotated[str | None, typer.Argument(metavar="UPN")] = None,
87
+ max_: Annotated[int, typer.Option("--max", min=1, help="Maximum levels to climb.")] = 10,
88
+ json_: JsonFlag = False,
89
+ ):
90
+ """Show the management chain from the user upward (self by default)."""
91
+ try:
92
+ levels = org.chain_expand(client, upn, max_levels=max_)
93
+ except GraphError as exc:
94
+ if exc.status not in (400, 403):
95
+ raise
96
+ gate(["User.Read.All"])
97
+ levels = org.chain_iterative(client, upn, max_levels=max_)
98
+ text = "\n".join(_chain_line(i, person) for i, person in enumerate(levels))
99
+ return TextResult(
100
+ text=text,
101
+ json_obj={"items": levels, "count": len(levels), "truncated": False},
102
+ )