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.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|