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,329 @@
1
+ """Teams chat commands (spec §8.8)."""
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
+ DryRunFlag,
12
+ JsonFlag,
13
+ LimitOpt,
14
+ gate,
15
+ graph_command,
16
+ make_noun_app,
17
+ page_bounds,
18
+ read_body,
19
+ )
20
+ from mgraphctl.errors import UsageError
21
+ from mgraphctl.graph import chats, teams, users
22
+ from mgraphctl.http import GraphClient
23
+ from mgraphctl.render import (
24
+ Column,
25
+ DryRunResult,
26
+ FileResult,
27
+ ListResult,
28
+ ObjectResult,
29
+ Window,
30
+ WriteResult,
31
+ dig,
32
+ fmt_dt,
33
+ fmt_size,
34
+ parse_dt,
35
+ truncate,
36
+ )
37
+
38
+ CREATE_SCOPES = ["Chat.Create"]
39
+ CHANNEL_SCOPES = ["ChannelMessage.Read.All"]
40
+
41
+ ChatArg = Annotated[str, typer.Argument(metavar="CHAT", help="Chat id (19:…) or a UPN.")]
42
+ BodyOpt = Annotated[str | None, typer.Option("--body", help="Message text.")]
43
+ BodyFileOpt = Annotated[
44
+ str | None, typer.Option("--body-file", help="File holding the text, or - for stdin.")
45
+ ]
46
+ HtmlFlag = Annotated[bool, typer.Option("--html", help="Send the body as HTML.")]
47
+
48
+ app = make_noun_app("Teams chats and direct messages.")
49
+
50
+
51
+ @app.command("list")
52
+ @graph_command(scopes=["Chat.Read"])
53
+ def list_(
54
+ client: GraphClient,
55
+ unread: Annotated[bool, typer.Option("--unread", help="Only chats with unread mail.")] = False,
56
+ chat_type: Annotated[
57
+ str | None, typer.Option("--type", help="oneOnOne, group or meeting.")
58
+ ] = None,
59
+ since: Annotated[
60
+ str | None,
61
+ typer.Option("--since", metavar="DT", help="Only chats with a message since this time."),
62
+ ] = None,
63
+ limit: LimitOpt = None,
64
+ all_: AllFlag = False,
65
+ json_: JsonFlag = False,
66
+ ):
67
+ """List your chats, most recently active first."""
68
+ tz = client.tz
69
+ since_dt = parse_dt(since, tz) if since else None
70
+ limit, all_ = page_bounds(limit, all_, default=20)
71
+ page = chats.list_chats(
72
+ client,
73
+ chat_type=chats.normalise_chat_type(chat_type),
74
+ since=since_dt,
75
+ limit=limit,
76
+ all_=all_,
77
+ )
78
+ items = [c for c in page.items if chats.is_unread(c)] if unread else page.items
79
+ oid = auth.my_oid()
80
+ columns = [
81
+ Column("id", "id"),
82
+ Column("flags", lambda c: "*" if chats.is_unread(c) else ""),
83
+ Column("type", "chatType"),
84
+ Column("updated", lambda c: fmt_dt(c.get("lastUpdatedDateTime"), tz)),
85
+ ]
86
+ if since_dt is not None:
87
+ # The column `--since` is measured against, so the boundary is visible in text mode.
88
+ columns.append(
89
+ Column(
90
+ "lastMessage",
91
+ lambda c: fmt_dt(dig(c, "lastMessagePreview.createdDateTime"), tz),
92
+ )
93
+ )
94
+ columns.append(Column("title", lambda c: truncate(chats.chat_title(c, oid))))
95
+ return ListResult(
96
+ items=items,
97
+ page=page,
98
+ window=Window(after=since_dt),
99
+ hit_cap=chats.CAP_CHATS if all_ else None,
100
+ empty_text="No chats.",
101
+ columns=columns,
102
+ )
103
+
104
+
105
+ @app.command("get")
106
+ @graph_command(scopes=["Chat.Read"])
107
+ def get(client: GraphClient, chat: ChatArg, json_: JsonFlag = False):
108
+ """Show one chat."""
109
+ chat_id = chats.resolve_chat(client, chat)
110
+ oid = auth.my_oid()
111
+ tz = client.tz
112
+ return ObjectResult(
113
+ obj=chats.get_chat(client, chat_id),
114
+ fields=[
115
+ ("Title", lambda c: chats.chat_title(c, oid)),
116
+ ("Type", "chatType"),
117
+ ("Updated", lambda c: fmt_dt(c.get("lastUpdatedDateTime"), tz)),
118
+ ("Web URL", "webUrl"),
119
+ ("Chat id", "id"),
120
+ ],
121
+ )
122
+
123
+
124
+ @app.command("members")
125
+ @graph_command(scopes=["Chat.Read"])
126
+ def members(client: GraphClient, chat: ChatArg, json_: JsonFlag = False):
127
+ """List a chat's members."""
128
+ chat_id = chats.resolve_chat(client, chat)
129
+ page = chats.list_chat_members(client, chat_id)
130
+ return ListResult(
131
+ items=page.items,
132
+ page=page,
133
+ supports_all=False,
134
+ empty_text="No members.",
135
+ columns=[
136
+ Column("id", "id"),
137
+ Column("name", "displayName"),
138
+ Column("email", "email"),
139
+ Column("userId", "userId"),
140
+ ],
141
+ )
142
+
143
+
144
+ @app.command("messages")
145
+ @graph_command(scopes=["Chat.Read"])
146
+ def messages(
147
+ client: GraphClient,
148
+ chat: ChatArg,
149
+ after: Annotated[
150
+ str | None, typer.Option("--after", metavar="DT", help="Only messages after this time.")
151
+ ] = None,
152
+ before: Annotated[
153
+ str | None, typer.Option("--before", metavar="DT", help="Only messages before this time.")
154
+ ] = None,
155
+ full: Annotated[
156
+ bool, typer.Option("--full", help="Print whole bodies, not 300 chars.")
157
+ ] = False,
158
+ limit: LimitOpt = None,
159
+ all_: AllFlag = False,
160
+ json_: JsonFlag = False,
161
+ ):
162
+ """List chat messages, oldest first in text mode."""
163
+ tz = client.tz
164
+ since = parse_dt(after, tz) if after else None
165
+ until = parse_dt(before, tz, end_of_day=True) if before else None
166
+ if since is not None and until is not None and since > until:
167
+ raise UsageError("USAGE", "--after must not be later than --before")
168
+ limit, all_ = page_bounds(limit, all_, default=20)
169
+ chat_id = chats.resolve_chat(client, chat)
170
+ page = chats.chat_messages(client, chat_id, after=since, before=until, limit=limit, all_=all_)
171
+ # JSON keeps the Graph order (newest first); text reads better oldest first (§10 quirk 17).
172
+ # A chat message is the same Graph resource as a channel message, rendered by `graph.teams`.
173
+ return ListResult(
174
+ items=page.items if json_ else list(reversed(page.items)),
175
+ page=page,
176
+ window=Window(after=since, before=until),
177
+ hit_cap=chats.CAP_MESSAGES if all_ else None,
178
+ empty_text="No messages.",
179
+ columns=teams.message_columns(client.tz, full),
180
+ )
181
+
182
+
183
+ @app.command("send")
184
+ @graph_command(scopes=["ChatMessage.Send"])
185
+ def send(
186
+ client: GraphClient,
187
+ chat: ChatArg,
188
+ body: BodyOpt = None,
189
+ body_file: BodyFileOpt = None,
190
+ html: HtmlFlag = False,
191
+ dry_run: DryRunFlag = False,
192
+ json_: JsonFlag = False,
193
+ ):
194
+ """Send a message to a chat."""
195
+ text = read_body(body, body_file)
196
+ chat_id = chats.resolve_chat(client, chat)
197
+ plan = chats.plan_chat_send(client, chat_id, body=text, html=html)
198
+ if dry_run:
199
+ return DryRunResult(plan)
200
+ return WriteResult(obj=client.execute(plan[0]), message="Sent.")
201
+
202
+
203
+ @app.command("dm")
204
+ @graph_command(scopes=["Chat.Read", "ChatMessage.Send"])
205
+ def dm(
206
+ client: GraphClient,
207
+ user: Annotated[str, typer.Argument(metavar="USER", help="UPN, user id, or 'me'.")],
208
+ body: BodyOpt = None,
209
+ body_file: BodyFileOpt = None,
210
+ html: HtmlFlag = False,
211
+ dry_run: DryRunFlag = False,
212
+ json_: JsonFlag = False,
213
+ ):
214
+ """Send a direct message, creating the 1:1 chat when there is none yet."""
215
+ text = read_body(body, body_file)
216
+ if dry_run:
217
+ # Both branches are shown; looking the chat up would be a request (spec §8.8).
218
+ return DryRunResult(chats.plan_dm(client, user, None, body=text, html=html))
219
+ who = users.get_user(client, user, select=chats.DM_SELECT)
220
+ sent = chats.run_dm(
221
+ client,
222
+ who,
223
+ body=text,
224
+ html=html,
225
+ my_oid=auth.my_oid(),
226
+ can_create=lambda: gate(CREATE_SCOPES),
227
+ )
228
+ return WriteResult(obj=sent, message="Sent.")
229
+
230
+
231
+ @app.command("create")
232
+ @graph_command(scopes=CREATE_SCOPES)
233
+ def create(
234
+ client: GraphClient,
235
+ member: Annotated[
236
+ list[str] | None,
237
+ typer.Option("--members", help="UPN or user id to add (repeatable, at least one)."),
238
+ ] = None,
239
+ topic: Annotated[str | None, typer.Option("--topic", help="Group chat topic.")] = None,
240
+ dry_run: DryRunFlag = False,
241
+ json_: JsonFlag = False,
242
+ ):
243
+ """Create a chat with the given members."""
244
+ plan = chats.plan_create_chat(
245
+ client, member_ids=member or [], topic=topic, my_oid=auth.my_oid()
246
+ )
247
+ if dry_run:
248
+ return DryRunResult(plan)
249
+ created = client.execute(plan[0]) or {}
250
+ return WriteResult(obj=created, message=f"Created chat {created.get('id')}.")
251
+
252
+
253
+ @app.command("search")
254
+ @graph_command(scopes=["Chat.Read"])
255
+ def search(
256
+ client: GraphClient,
257
+ q: Annotated[str, typer.Argument(metavar="Q", help="What to look for.")],
258
+ after: Annotated[
259
+ str | None, typer.Option("--after", metavar="DT", help="Only hits after this time.")
260
+ ] = None,
261
+ before: Annotated[
262
+ str | None, typer.Option("--before", metavar="DT", help="Only hits before this time.")
263
+ ] = None,
264
+ limit: LimitOpt = None,
265
+ all_: AllFlag = False,
266
+ json_: JsonFlag = False,
267
+ ):
268
+ """Search your chat and channel messages. Hits carry a snippet, not the whole body."""
269
+ limit, all_ = page_bounds(limit, all_, default=25)
270
+ tz = client.tz
271
+ after_dt = parse_dt(after, tz) if after else None
272
+ before_dt = parse_dt(before, tz, end_of_day=True) if before else None
273
+ found = chats.search_chat_messages(
274
+ client,
275
+ q,
276
+ after=after_dt,
277
+ before=before_dt,
278
+ limit=chats.CAP_SEARCH if all_ else limit,
279
+ )
280
+ return ListResult(
281
+ items=[chats.shape_chat_hit(hit) for hit in found.hits],
282
+ truncated=found.more,
283
+ fetched=found.fetched_count,
284
+ cap=found.cap,
285
+ query=found.query,
286
+ window=Window(after=after_dt, before=before_dt),
287
+ hit_cap=chats.CAP_SEARCH if all_ else None,
288
+ empty_text="No messages found.",
289
+ columns=[
290
+ Column("id", "id"),
291
+ Column("created", lambda h: fmt_dt(h.get("created"), tz)),
292
+ Column("from", "from"),
293
+ Column("where", "where"),
294
+ Column("summary", lambda h: truncate(h.get("summary"))),
295
+ ],
296
+ )
297
+
298
+
299
+ @app.command("hosted-content")
300
+ @graph_command(scopes=[])
301
+ def hosted_content(
302
+ client: GraphClient,
303
+ chat: Annotated[
304
+ str, typer.Argument(metavar="CHAT|URL", help="Chat id, or a full hostedContents URL.")
305
+ ],
306
+ msg_id: Annotated[str | None, typer.Argument(metavar="[MSGID]", help="Message id.")] = None,
307
+ hc_id: Annotated[
308
+ str | None, typer.Argument(metavar="[HCID]", help="Hosted content id.")
309
+ ] = None,
310
+ output: Annotated[
311
+ Path | None, typer.Option("--output", help="Where to write it; default is sniffed.")
312
+ ] = None,
313
+ json_: JsonFlag = False,
314
+ ):
315
+ """Download an image or file hosted inside a Teams message."""
316
+ target, hosted_id, is_channel = chats.hosted_content_target(chat, msg_id, hc_id)
317
+ # A channel URL reads channel messages, not chats, so the gate follows the form (spec §8.8).
318
+ gate(CHANNEL_SCOPES if is_channel else ["Chat.Read"])
319
+ # The name's extension comes from the bytes, so they are read before the file is opened.
320
+ data = client.request("GET", target, expect="bytes") or b""
321
+ dest = output or Path(chats.hosted_content_name(hosted_id, data))
322
+ dest.parent.mkdir(parents=True, exist_ok=True)
323
+ dest.write_bytes(data)
324
+ return FileResult(
325
+ path=dest,
326
+ bytes=len(data),
327
+ meta={},
328
+ message=f"Downloaded {dest.name} ({fmt_size(len(data))}) to {dest}",
329
+ )
@@ -0,0 +1,140 @@
1
+ """`config path|show|init`: the config file, its resolved values and where each came from."""
2
+
3
+ import contextlib
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from mgraphctl import config, render
12
+ from mgraphctl.cli import Globals, JsonFlag, make_noun_app
13
+ from mgraphctl.errors import UsageError
14
+ from mgraphctl.render import TextResult, WriteResult
15
+
16
+ app = make_noun_app("The config file: where it is, what it resolves to, and a starter template.")
17
+
18
+
19
+ @app.command("path")
20
+ def path_(json_: JsonFlag = False) -> None:
21
+ """Print the config file path in use."""
22
+ p = config.config_path()
23
+ render.emit(
24
+ TextResult(text=str(p), json_obj=dict(path=str(p), exists=p.exists())), json_mode=json_
25
+ )
26
+
27
+
28
+ @app.command("show")
29
+ def show(ctx: typer.Context, json_: JsonFlag = False) -> None:
30
+ """Every setting with its effective value and source (flag, env, file or default)."""
31
+ g: Globals = ctx.find_root().obj
32
+ rows = []
33
+ for key, value, source in config.effective_settings():
34
+ # The root callback already resolved these two (flag, detection): report what it uses.
35
+ if key == "tz":
36
+ value = g.tz
37
+ elif key == "debug":
38
+ value = g.debug
39
+ if key in g.flag_keys:
40
+ source = "flag"
41
+ rows.append((key, value, source))
42
+ unknown = config.unknown_config_keys()
43
+ p = config.config_path()
44
+ width = max(len(k) for k, _, _ in rows)
45
+ vwidth = max(len(str(v)) for _, v, _ in rows)
46
+ lines = [f"Config file: {p}"]
47
+ lines += [f" {k:<{width}} {str(v):<{vwidth}} {src}" for k, v, src in rows]
48
+ if unknown:
49
+ lines.append(f"Unknown keys ignored: {', '.join(unknown)}")
50
+ payload = dict(
51
+ path=str(p),
52
+ settings={k: v for k, v, _ in rows},
53
+ sources={k: src for k, _, src in rows},
54
+ unknownKeys=unknown,
55
+ )
56
+ render.emit(TextResult(text="\n".join(lines), json_obj=payload), json_mode=json_)
57
+
58
+
59
+ @app.command("init")
60
+ def init(
61
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
62
+ json_: JsonFlag = False,
63
+ ) -> None:
64
+ """Write a commented template with every key at its default."""
65
+ p = config.config_path()
66
+ existed = p.exists()
67
+ if existed and not force:
68
+ raise UsageError("USAGE", f"{p} already exists", hint="pass --force to overwrite it")
69
+ _write(p, config.CONFIG_TEMPLATE)
70
+ render.emit(
71
+ WriteResult(obj=dict(path=str(p), overwritten=existed), message=f"Wrote {p}"),
72
+ json_mode=json_,
73
+ )
74
+
75
+
76
+ @app.command("set")
77
+ def set_(
78
+ key: Annotated[str, typer.Argument(help="One of the config keys (see `config show`).")],
79
+ value: Annotated[str, typer.Argument(help="The value; integers for the numeric keys.")],
80
+ json_: JsonFlag = False,
81
+ ) -> None:
82
+ """Set one key in the config file, creating the file when it does not exist."""
83
+ parsed = config.parse_config_value(key, value)
84
+ if key == "tz" and not render.is_iana(value):
85
+ raise UsageError("USAGE", f"unknown time zone {value!r}; use an IANA name")
86
+ p = config.config_path()
87
+ config.load_config_file(p) # a file that is not valid TOML must be fixed by hand first
88
+ _write(p, config.upsert_config_line(_read(p), key, parsed))
89
+ message = f"Set {key} = {config.format_toml_value(parsed)} in {p}"
90
+ render.emit(
91
+ WriteResult(obj=dict(path=str(p), key=key, value=parsed), message=message),
92
+ json_mode=json_,
93
+ )
94
+
95
+
96
+ @app.command("unset")
97
+ def unset(
98
+ key: Annotated[str, typer.Argument(help="One of the config keys (see `config show`).")],
99
+ json_: JsonFlag = False,
100
+ ) -> None:
101
+ """Comment a key out of the config file, so the environment or the default applies."""
102
+ if key not in config.CONFIG_KEYS:
103
+ raise UsageError(
104
+ "USAGE",
105
+ f"unknown config key {key!r}",
106
+ hint=f"one of: {', '.join(config.CONFIG_KEYS)}",
107
+ )
108
+ p = config.config_path()
109
+ removed = key in config.load_config_file(p)
110
+ if removed:
111
+ _write(p, config.upsert_config_line(_read(p), key, None))
112
+ render.emit(
113
+ WriteResult(obj=dict(path=str(p), key=key, removed=removed), message=f"Unset {key} in {p}"),
114
+ json_mode=json_,
115
+ )
116
+
117
+
118
+ def _read(p: Path) -> str:
119
+ try:
120
+ return p.read_text()
121
+ except FileNotFoundError:
122
+ return ""
123
+
124
+
125
+ def _write(p: Path, text: str) -> None:
126
+ """Atomic 0600 write into a 0700 directory, the same way the token cache is saved."""
127
+ p.parent.mkdir(parents=True, exist_ok=True)
128
+ with contextlib.suppress(OSError):
129
+ p.parent.chmod(0o700)
130
+ fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=f"{p.name}.", suffix=".tmp")
131
+ try:
132
+ os.fchmod(fd, 0o600)
133
+ os.write(fd, text.encode())
134
+ finally:
135
+ os.close(fd)
136
+ os.replace(tmp, p)
137
+
138
+
139
+ def register(root: typer.Typer) -> None:
140
+ root.add_typer(app, name="config")
@@ -0,0 +1,65 @@
1
+ """Microsoft 365 groups (spec §8.16)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import AllFlag, JsonFlag, LimitOpt, graph_command, make_noun_app, page_bounds
8
+ from mgraphctl.graph import groups
9
+ from mgraphctl.http import GraphClient
10
+ from mgraphctl.render import Column, ListResult
11
+
12
+ app = make_noun_app("Microsoft 365 groups.")
13
+
14
+
15
+ @app.command("list")
16
+ @graph_command(scopes=["User.Read"])
17
+ def list_(
18
+ client: GraphClient,
19
+ unified: Annotated[bool, typer.Option("--unified", help="Only Microsoft 365 groups.")] = False,
20
+ limit: LimitOpt = None,
21
+ all_: AllFlag = False,
22
+ json_: JsonFlag = False,
23
+ ):
24
+ """List the groups the signed-in user belongs to."""
25
+ limit, all_ = page_bounds(limit, all_, default=20)
26
+ page = groups.list_groups(client, unified=unified, limit=limit, all_=all_)
27
+ return ListResult(
28
+ items=page.items,
29
+ page=page,
30
+ hit_cap=groups.CAP if all_ else None,
31
+ columns=[
32
+ Column("id", "id"),
33
+ Column("name", "displayName"),
34
+ Column("mail", "mail"),
35
+ Column("types", "groupTypes"),
36
+ Column("description", "description"),
37
+ ],
38
+ )
39
+
40
+
41
+ @app.command("members")
42
+ @graph_command(scopes=["Group.Read.All"])
43
+ def members(
44
+ client: GraphClient,
45
+ group: Annotated[str, typer.Argument(metavar="GROUP")],
46
+ limit: LimitOpt = None,
47
+ all_: AllFlag = False,
48
+ json_: JsonFlag = False,
49
+ ):
50
+ """List a group's members, by group name or id."""
51
+ limit, all_ = page_bounds(limit, all_, default=20)
52
+ resolved = groups.resolve_group(client, group)
53
+ page = groups.list_members(client, resolved["id"], limit=limit, all_=all_)
54
+ return ListResult(
55
+ items=page.items,
56
+ page=page,
57
+ hit_cap=groups.CAP if all_ else None,
58
+ columns=[
59
+ Column("id", "id"),
60
+ Column("name", "displayName"),
61
+ Column("upn", "userPrincipalName"),
62
+ Column("mail", "mail"),
63
+ Column("title", "jobTitle"),
64
+ ],
65
+ )