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,253 @@
1
+ """Microsoft To Do commands: lists and tasks (spec §8.15)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from mgraphctl.cli import (
8
+ AllFlag,
9
+ DryRunFlag,
10
+ JsonFlag,
11
+ LimitOpt,
12
+ graph_command,
13
+ make_noun_app,
14
+ page_bounds,
15
+ )
16
+ from mgraphctl.errors import UsageError
17
+ from mgraphctl.graph import todo
18
+ from mgraphctl.http import GraphClient
19
+ from mgraphctl.render import (
20
+ Column,
21
+ DryRunResult,
22
+ ListResult,
23
+ ObjectResult,
24
+ WriteResult,
25
+ fmt_dtz,
26
+ parse_dt,
27
+ )
28
+
29
+ app = make_noun_app("Microsoft To Do lists and tasks.")
30
+
31
+ SCOPES = ["Tasks.ReadWrite"]
32
+ SCOPES_FROM_MAIL = ["Tasks.ReadWrite", "Mail.Read"]
33
+ DEFAULT_LIMIT = 50
34
+
35
+ LIST_COLUMNS = [
36
+ Column("id", "id"),
37
+ Column("wellknown", lambda item: item.get("wellknownListName") or ""),
38
+ Column("name", "displayName"),
39
+ ]
40
+
41
+
42
+ def _task_columns(tz: str) -> list[Column]:
43
+ return [
44
+ Column("id", "id"),
45
+ Column("status", "status"),
46
+ Column("importance", "importance"),
47
+ Column("due", lambda t: fmt_dtz(t.get("dueDateTime"), tz)),
48
+ Column("title", "title"),
49
+ ]
50
+
51
+
52
+ @app.command("lists")
53
+ @graph_command(scopes=SCOPES)
54
+ def lists(client: GraphClient, json_: JsonFlag = False):
55
+ """List your To Do lists."""
56
+ return ListResult(items=todo.list_lists(client), columns=LIST_COLUMNS)
57
+
58
+
59
+ @app.command("tasks")
60
+ @graph_command(scopes=SCOPES)
61
+ def tasks(
62
+ client: GraphClient,
63
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
64
+ include_completed: Annotated[bool, typer.Option("--include-completed")] = False,
65
+ limit: LimitOpt = None,
66
+ all_: AllFlag = False,
67
+ json_: JsonFlag = False,
68
+ ):
69
+ """List the tasks of a To Do list."""
70
+ bounded_limit, bounded_all = page_bounds(limit, all_, default=DEFAULT_LIMIT)
71
+ resolved = todo.resolve_list(client, list_ref)
72
+ page = todo.list_tasks(
73
+ client,
74
+ resolved["id"],
75
+ include_completed=include_completed,
76
+ limit=bounded_limit,
77
+ all_=bounded_all,
78
+ )
79
+ return ListResult(
80
+ items=page.items,
81
+ page=page,
82
+ hit_cap=todo.CAP_TASKS if bounded_all else None,
83
+ columns=_task_columns(client.tz),
84
+ )
85
+
86
+
87
+ @app.command("task")
88
+ @graph_command(scopes=SCOPES)
89
+ def task(
90
+ client: GraphClient,
91
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
92
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
93
+ json_: JsonFlag = False,
94
+ ):
95
+ """Show one task."""
96
+ resolved = todo.resolve_list(client, list_ref)
97
+ obj = todo.get_task(client, resolved["id"], task_id)
98
+ fields = [
99
+ ("Title", "title"),
100
+ ("Status", "status"),
101
+ ("Importance", "importance"),
102
+ ("Due", lambda o: fmt_dtz(o.get("dueDateTime"), client.tz)),
103
+ ]
104
+ return ObjectResult(obj=obj, fields=fields)
105
+
106
+
107
+ def _create_opts(
108
+ *,
109
+ title,
110
+ due,
111
+ body,
112
+ importance,
113
+ reminder,
114
+ start,
115
+ tz,
116
+ ) -> dict:
117
+ return dict(
118
+ title=title,
119
+ due=parse_dt(due, tz) if due is not None else None,
120
+ body_text=body,
121
+ importance=importance,
122
+ reminder=parse_dt(reminder, tz) if reminder is not None else None,
123
+ start=parse_dt(start, tz) if start is not None else None,
124
+ tz=tz,
125
+ )
126
+
127
+
128
+ @app.command("create")
129
+ @graph_command(scopes=SCOPES)
130
+ def create(
131
+ client: GraphClient,
132
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
133
+ title: Annotated[str, typer.Option("--title")],
134
+ due: Annotated[str | None, typer.Option("--due")] = None,
135
+ body: Annotated[str | None, typer.Option("--body")] = None,
136
+ importance: Annotated[str | None, typer.Option("--importance")] = None,
137
+ reminder: Annotated[str | None, typer.Option("--reminder")] = None,
138
+ start: Annotated[str | None, typer.Option("--start")] = None,
139
+ dry_run: DryRunFlag = False,
140
+ json_: JsonFlag = False,
141
+ ):
142
+ """Create a task."""
143
+ resolved = todo.resolve_list(client, list_ref)
144
+ opts = _create_opts(
145
+ title=title,
146
+ due=due,
147
+ body=body,
148
+ importance=importance,
149
+ reminder=reminder,
150
+ start=start,
151
+ tz=client.tz,
152
+ )
153
+ if dry_run:
154
+ return DryRunResult(todo.plan_create(client, resolved["id"], **opts))
155
+ obj = todo.run_create(client, resolved["id"], **opts)
156
+ return WriteResult(obj=obj, message=f"Created task {obj.get('id')}.")
157
+
158
+
159
+ @app.command("update")
160
+ @graph_command(scopes=SCOPES)
161
+ def update(
162
+ client: GraphClient,
163
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
164
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
165
+ title: Annotated[str | None, typer.Option("--title")] = None,
166
+ due: Annotated[str | None, typer.Option("--due")] = None,
167
+ body: Annotated[str | None, typer.Option("--body")] = None,
168
+ importance: Annotated[str | None, typer.Option("--importance")] = None,
169
+ reminder: Annotated[str | None, typer.Option("--reminder")] = None,
170
+ start: Annotated[str | None, typer.Option("--start")] = None,
171
+ status: Annotated[str | None, typer.Option("--status")] = None,
172
+ dry_run: DryRunFlag = False,
173
+ json_: JsonFlag = False,
174
+ ):
175
+ """Update a task."""
176
+ if not any([title, due, body, importance, reminder, start, status]):
177
+ raise UsageError("USAGE", "give at least one field to update")
178
+ resolved = todo.resolve_list(client, list_ref)
179
+ opts = _create_opts(
180
+ title=title,
181
+ due=due,
182
+ body=body,
183
+ importance=importance,
184
+ reminder=reminder,
185
+ start=start,
186
+ tz=client.tz,
187
+ )
188
+ opts["status"] = status
189
+ if dry_run:
190
+ return DryRunResult(todo.plan_update(client, resolved["id"], task_id, **opts))
191
+ obj = todo.run_update(client, resolved["id"], task_id, **opts)
192
+ return WriteResult(obj=obj, message=f"Updated task {task_id}.")
193
+
194
+
195
+ @app.command("complete")
196
+ @graph_command(scopes=SCOPES)
197
+ def complete(
198
+ client: GraphClient,
199
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
200
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
201
+ dry_run: DryRunFlag = False,
202
+ json_: JsonFlag = False,
203
+ ):
204
+ """Mark a task complete."""
205
+ resolved = todo.resolve_list(client, list_ref)
206
+ if dry_run:
207
+ return DryRunResult(todo.plan_complete(client, resolved["id"], task_id, tz=client.tz))
208
+ obj = todo.run_complete(client, resolved["id"], task_id, tz=client.tz)
209
+ return WriteResult(obj=obj, message=f"Completed task {task_id}.")
210
+
211
+
212
+ @app.command("delete")
213
+ @graph_command(scopes=SCOPES)
214
+ def delete(
215
+ client: GraphClient,
216
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
217
+ task_id: Annotated[str, typer.Argument(metavar="ID")],
218
+ dry_run: DryRunFlag = False,
219
+ json_: JsonFlag = False,
220
+ ):
221
+ """Delete a task."""
222
+ resolved = todo.resolve_list(client, list_ref)
223
+ if dry_run:
224
+ return DryRunResult(todo.plan_delete(client, resolved["id"], task_id))
225
+ todo.run_delete(client, resolved["id"], task_id)
226
+ return WriteResult(obj={"status": "deleted", "id": task_id}, message=f"Deleted task {task_id}.")
227
+
228
+
229
+ @app.command("from-mail")
230
+ @graph_command(scopes=SCOPES_FROM_MAIL)
231
+ def from_mail(
232
+ client: GraphClient,
233
+ list_ref: Annotated[str, typer.Argument(metavar="LIST")],
234
+ message_id: Annotated[str, typer.Argument(metavar="MSGID")],
235
+ title: Annotated[str | None, typer.Option("--title")] = None,
236
+ due: Annotated[str | None, typer.Option("--due")] = None,
237
+ importance: Annotated[str | None, typer.Option("--importance")] = None,
238
+ dry_run: DryRunFlag = False,
239
+ json_: JsonFlag = False,
240
+ ):
241
+ """Create a task linked to a mail message."""
242
+ resolved = todo.resolve_list(client, list_ref)
243
+ message = todo.fetch_message_for_task(client, message_id)
244
+ opts = dict(
245
+ title=title,
246
+ due=parse_dt(due, client.tz) if due is not None else None,
247
+ importance=importance,
248
+ tz=client.tz,
249
+ )
250
+ if dry_run:
251
+ return DryRunResult(todo.plan_from_mail(client, resolved["id"], message, **opts))
252
+ obj = todo.run_from_mail(client, resolved["id"], message, **opts)
253
+ return WriteResult(obj=obj, message=f"Created task {obj.get('id')}.")
@@ -0,0 +1,284 @@
1
+ """login, logout, status, claims, me and version (spec §4.2, §4.3, §4.6, §8.1)."""
2
+
3
+ import importlib.metadata
4
+ import platform
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+ from zoneinfo import ZoneInfo
10
+
11
+ import typer
12
+
13
+ from mgraphctl import __version__, auth, config, errors, render
14
+ from mgraphctl.cli import Globals, JsonFlag, graph_command, open_client
15
+ from mgraphctl.errors import AuthError
16
+ from mgraphctl.graph import users
17
+ from mgraphctl.http import GraphClient
18
+ from mgraphctl.render import FileResult, ObjectResult, TextResult, WriteResult, fmt_size
19
+
20
+ LOGIN_SELECT = "id,displayName,userPrincipalName"
21
+
22
+ ME_FIELDS = [
23
+ ("Name", "displayName"),
24
+ ("UPN", "userPrincipalName"),
25
+ ("Mail", "mail"),
26
+ ("Title", "jobTitle"),
27
+ ("Dept", "department"),
28
+ ("Office", "officeLocation"),
29
+ ("Phones", lambda u: ", ".join(u.get("businessPhones") or [])),
30
+ ("Mobile", "mobilePhone"),
31
+ ("Lang", "preferredLanguage"),
32
+ ("User id", "id"),
33
+ ]
34
+
35
+ NO_DEVICE_NOTE = (
36
+ "none (Conditional Access policies that require a compliant device will fail without it)"
37
+ )
38
+
39
+
40
+ def _iso_local(epoch: object, tz: str) -> str | None:
41
+ """An `exp`/`iat` epoch rendered in `tz` with seconds, or None."""
42
+ if epoch is None:
43
+ return None
44
+ return datetime.fromtimestamp(int(epoch), ZoneInfo(tz)).isoformat(timespec="seconds")
45
+
46
+
47
+ # --------------------------------------------------------------------------- login / logout
48
+
49
+
50
+ def _already_logged_in(wanted: list[str], name: str) -> TextResult | None:
51
+ """The current token, when it already carries every requested scope (spec §4.2)."""
52
+ try:
53
+ result = auth.acquire_silent()
54
+ except AuthError:
55
+ return None
56
+ claims = auth.decode_jwt(result["access_token"])
57
+ held = auth.expand_scopes(claims.get("scp", "").split())
58
+ if any(s not in held for s in config.msal_scopes(wanted)):
59
+ return None
60
+ upn = auth.account_upn() or claims.get("upn") or claims.get("unique_name")
61
+ text = (
62
+ f"Already logged in as: {upn}\n"
63
+ "Use --force to re-authenticate, --scopes extended to add permissions."
64
+ )
65
+ store = auth.store_info()
66
+ payload = dict(
67
+ account=upn,
68
+ scopes=wanted,
69
+ scopeSet=name,
70
+ cache=store.label,
71
+ store=store.kind,
72
+ alreadyLoggedIn=True,
73
+ )
74
+ return TextResult(text=text, json_obj=payload)
75
+
76
+
77
+ def login(
78
+ ctx: typer.Context,
79
+ scopes: Annotated[
80
+ str,
81
+ typer.Option(
82
+ "--scopes",
83
+ envvar="MGRAPHCTL_SCOPES",
84
+ help="default, extended, or a space/comma-separated scope list"
85
+ " (else MGRAPHCTL_SCOPES, the config file).",
86
+ ),
87
+ ] = "",
88
+ scope: Annotated[
89
+ list[str] | None,
90
+ typer.Option("--scope", help="Ask for one extra scope on demand (repeatable)."),
91
+ ] = None,
92
+ force: Annotated[
93
+ bool, typer.Option("--force", help="Sign in again even when a token is cached.")
94
+ ] = False,
95
+ device_code: Annotated[
96
+ bool, typer.Option("--device-code", help="Use the device code flow (no browser).")
97
+ ] = False,
98
+ json_: JsonFlag = False,
99
+ ) -> None:
100
+ """Sign in and cache the token. The only command that may open a browser."""
101
+ g: Globals = ctx.find_root().obj
102
+ s = config.settings()
103
+ name, wanted = config.resolve_scopes(scopes or s.scope_spec, scope or [])
104
+ if not force:
105
+ already = _already_logged_in(wanted, name)
106
+ if already is not None:
107
+ render.emit(already, json_mode=json_)
108
+ return
109
+ sent = config.msal_scopes(wanted)
110
+ if device_code:
111
+ auth.login_device_code(sent)
112
+ else:
113
+ auth.login_interactive(sent, force=force)
114
+ with open_client(g, []) as client:
115
+ me_obj = client.get("/me", params={"$select": LOGIN_SELECT})
116
+ account = me_obj.get("userPrincipalName")
117
+ display = me_obj.get("displayName")
118
+ # Asked after signing in: a keychain that failed by now has been swapped for the file.
119
+ store = auth.store_info()
120
+ text = (
121
+ f"Logged in as: {display} <{account}>\nScopes: {name} ({len(wanted)})\nCache: {store.label}"
122
+ )
123
+ payload = dict(
124
+ account=account,
125
+ displayName=display,
126
+ userId=me_obj.get("id"),
127
+ scopes=wanted,
128
+ scopeSet=name,
129
+ cache=store.label,
130
+ store=store.kind,
131
+ )
132
+ render.emit(TextResult(text=text, json_obj=payload), json_mode=json_)
133
+
134
+
135
+ def logout(json_: JsonFlag = False) -> None:
136
+ """Delete the cached sign-in."""
137
+ store, removed = auth.logout()
138
+ message = (
139
+ f"Logged out. Cache removed: {store.label}" if removed else "No cached credentials found."
140
+ )
141
+ payload = dict(loggedOut=removed, cache=store.label if removed else None, store=store.kind)
142
+ render.emit(WriteResult(obj=payload, message=message), json_mode=json_)
143
+
144
+
145
+ # --------------------------------------------------------------------------- status / claims
146
+
147
+
148
+ def status(ctx: typer.Context, json_: JsonFlag = False) -> None:
149
+ """Report whether a cached sign-in is usable, and until when (spec §4.6)."""
150
+ g: Globals = ctx.find_root().obj
151
+ s = config.settings()
152
+ try:
153
+ result = auth.acquire_silent()
154
+ except AuthError:
155
+ # JSON callers parse stdout without checking the exit code first (spec §4.6).
156
+ if json_:
157
+ store = auth.store_info()
158
+ out = dict(
159
+ loggedIn=False,
160
+ account=None,
161
+ expiresAt=None,
162
+ scopeSet=s.scope_set,
163
+ scopes=s.scopes,
164
+ cache=store.label,
165
+ store=store.kind,
166
+ )
167
+ render.emit(TextResult(text="", json_obj=out), json_mode=True)
168
+ raise
169
+ claims = auth.decode_jwt(result["access_token"])
170
+ account = auth.account_upn() or claims.get("upn") or claims.get("unique_name")
171
+ expires = _iso_local(claims.get("exp"), g.tz)
172
+ store = auth.store_info()
173
+ text = (
174
+ f"Logged in as: {account}\n"
175
+ f"Token expires: {expires}\n"
176
+ f"Scopes: {s.scope_set} ({len(s.scopes)})\n"
177
+ f"Cache: {store.label}"
178
+ )
179
+ payload = dict(
180
+ loggedIn=True,
181
+ account=account,
182
+ expiresAt=expires,
183
+ scopeSet=s.scope_set,
184
+ scopes=s.scopes,
185
+ cache=store.label,
186
+ store=store.kind,
187
+ )
188
+ render.emit(TextResult(text=text, json_obj=payload), json_mode=json_)
189
+
190
+
191
+ def _expiry_note(exp: object) -> str:
192
+ if exp is None:
193
+ return ""
194
+ remaining = int(exp) - int(time.time())
195
+ return "EXPIRED" if remaining <= 0 else f"expires in {remaining // 60}m"
196
+
197
+
198
+ def _section(title: str, rows: list[tuple[str, str]]) -> list[str]:
199
+ width = max((len(label) for label, _ in rows), default=0)
200
+ body = [f" {label:<{width}} : {value}" for label, value in rows]
201
+ return [title] + body
202
+
203
+
204
+ def _claims_text(payload: dict, tz: str) -> str:
205
+ """The IDENTITY / DEVICE / AUTH METHODS / SCOPES report of spec §4.6."""
206
+ exp = payload.get("exp")
207
+ expires = _iso_local(exp, tz) or "N/A"
208
+ note = _expiry_note(exp)
209
+ identity = [
210
+ ("UPN", str(payload.get("upn") or payload.get("unique_name") or "N/A")),
211
+ ("Object id", str(payload.get("oid") or "N/A")),
212
+ ("Tenant id", str(payload.get("tid") or "N/A")),
213
+ ("Issued", _iso_local(payload.get("iat"), tz) or "N/A"),
214
+ ("Expires", f"{expires} ({note})" if note else expires),
215
+ ]
216
+ device = [
217
+ ("Device id", str(payload.get("deviceid") or NO_DEVICE_NOTE)),
218
+ ("Join type", str(payload.get("join_type") or "N/A")),
219
+ ]
220
+ amr = payload.get("amr") or []
221
+ methods = ", ".join(str(a) for a in amr) if amr else "N/A"
222
+ lines = _section("IDENTITY", identity) + [""]
223
+ lines += _section("DEVICE", device) + [""]
224
+ lines += ["AUTH METHODS", f" {methods}", ""]
225
+ lines += ["SCOPES"] + [f" {s}" for s in sorted(payload.get("scp", "").split())]
226
+ return "\n".join(lines)
227
+
228
+
229
+ def claims(ctx: typer.Context, json_: JsonFlag = False) -> None:
230
+ """Decode the cached access token locally. No network call (spec §4.6)."""
231
+ g: Globals = ctx.find_root().obj
232
+ token = auth.cached_access_token()
233
+ if not token:
234
+ raise AuthError("NOT_LOGGED_IN", "no cached token", hint=errors.HINTS["NOT_LOGGED_IN"])
235
+ payload = auth.decode_jwt(token)
236
+ render.emit(TextResult(text=_claims_text(payload, g.tz), json_obj=payload), json_mode=json_)
237
+
238
+
239
+ # --------------------------------------------------------------------------- me / version
240
+
241
+
242
+ @graph_command(scopes=["User.Read"])
243
+ def me(
244
+ client: GraphClient,
245
+ photo: Annotated[
246
+ Path | None, typer.Option("--photo", help="Save the profile photo to this file.")
247
+ ] = None,
248
+ json_: JsonFlag = False,
249
+ ):
250
+ """Show the signed-in user's profile, or save their photo."""
251
+ if photo is not None:
252
+ got = client.download("/me/photo/$value", photo)
253
+ return FileResult(
254
+ path=got.path,
255
+ bytes=got.bytes,
256
+ meta=dict(contentType=got.content_type),
257
+ message=f"Downloaded photo ({fmt_size(got.bytes)}) to {got.path}",
258
+ )
259
+ return ObjectResult(obj=users.get_me(client), fields=list(ME_FIELDS))
260
+
261
+
262
+ def version(json_: JsonFlag = False) -> None:
263
+ """Show the versions of mgraphctl and the libraries it runs on."""
264
+ python_version = platform.python_version()
265
+ msal_version = importlib.metadata.version("msal")
266
+ httpx_version = importlib.metadata.version("httpx")
267
+ text = (
268
+ f"mgraphctl {__version__} (python {python_version},"
269
+ f" msal {msal_version}, httpx {httpx_version})"
270
+ )
271
+ payload = dict(
272
+ version=__version__, python=python_version, msal=msal_version, httpx=httpx_version
273
+ )
274
+ render.emit(TextResult(text=text, json_obj=payload), json_mode=json_)
275
+
276
+
277
+ def register(root: typer.Typer) -> None:
278
+ """Attach the top-level verbs to the root app."""
279
+ root.command("login")(login)
280
+ root.command("logout")(logout)
281
+ root.command("status")(status)
282
+ root.command("claims")(claims)
283
+ root.command("me")(me)
284
+ root.command("version")(version)