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
mgraphctl/__init__.py
ADDED
mgraphctl/__main__.py
ADDED
mgraphctl/auth.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""msal sign-in, token cache and the local scope gate (spec §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import msal
|
|
15
|
+
from msal.oauth2cli.oauth2 import BrowserInteractionTimeoutError
|
|
16
|
+
|
|
17
|
+
from mgraphctl import config, errors, token_store
|
|
18
|
+
from mgraphctl.errors import AuthError, UsageError
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("mgraphctl.auth")
|
|
21
|
+
|
|
22
|
+
# Filled by cli.graph_command at decoration time: every scope any command declares.
|
|
23
|
+
DECLARED_SCOPES: set[str] = set()
|
|
24
|
+
|
|
25
|
+
# Error-description fragments that mean "an admin has to consent" rather than "sign in again".
|
|
26
|
+
CONSENT_MARKERS = ("AADSTS65001", "AADSTS650052", "Need admin approval")
|
|
27
|
+
|
|
28
|
+
FIXTURE_UPN = "fixture-user@example.com"
|
|
29
|
+
FIXTURE_OID = "00000000-0000-0000-0000-000000000001"
|
|
30
|
+
FIXTURE_TID = "00000000-0000-0000-0000-000000000002"
|
|
31
|
+
|
|
32
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
33
|
+
|
|
34
|
+
_app: msal.PublicClientApplication | None = None
|
|
35
|
+
_cache: msal.SerializableTokenCache | None = None
|
|
36
|
+
# Resolved by load_cache; flipped to the file store by _fall_back_to_file on a keyring failure.
|
|
37
|
+
_store: token_store.Store | None = None
|
|
38
|
+
# The plaintext file an earlier version left behind, deleted once the keychain holds its content.
|
|
39
|
+
_migrate_from: Path | None = None
|
|
40
|
+
_warned = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- app and cache
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_app(
|
|
47
|
+
s: config.Settings, cache: msal.SerializableTokenCache
|
|
48
|
+
) -> msal.PublicClientApplication:
|
|
49
|
+
return msal.PublicClientApplication(s.client_id, authority=s.authority, token_cache=cache)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def app() -> msal.PublicClientApplication:
|
|
53
|
+
"""The process-wide msal application, built on first use."""
|
|
54
|
+
global _app, _cache
|
|
55
|
+
if _app is None:
|
|
56
|
+
s = config.settings()
|
|
57
|
+
if s.client_id == config.CLIENT_ID_DEFAULT:
|
|
58
|
+
# Entra would answer AADSTS700016 for the placeholder; say what is missing instead.
|
|
59
|
+
raise UsageError(
|
|
60
|
+
"CONFIG",
|
|
61
|
+
"client_id is not set",
|
|
62
|
+
hint="register a public-client Entra application (or ask your admin for its id),"
|
|
63
|
+
f" then run '{config.shim_path()} config set client_id <id>'"
|
|
64
|
+
" or export MGRAPHCTL_CLIENT_ID",
|
|
65
|
+
)
|
|
66
|
+
_cache = load_cache(s)
|
|
67
|
+
_app = _build_app(s, _cache)
|
|
68
|
+
return _app
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def store_info() -> token_store.Store:
|
|
72
|
+
"""The store in use, or the one the settings select when nothing has been loaded yet."""
|
|
73
|
+
return _store if _store is not None else token_store.resolve(config.settings())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fall_back_to_file(exc: Exception) -> None:
|
|
77
|
+
"""Switch to the 0600 file for the rest of the process, saying so once on stderr."""
|
|
78
|
+
global _store, _warned
|
|
79
|
+
assert _store is not None
|
|
80
|
+
_store = token_store.Store("file", _store.path)
|
|
81
|
+
log.debug("keyring store failed: %s", exc)
|
|
82
|
+
if not _warned:
|
|
83
|
+
_warned = True
|
|
84
|
+
print(f"warning: token_store keyring: {exc}; using {_store.path}", file=sys.stderr)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_store() -> str | None:
|
|
88
|
+
assert _store is not None
|
|
89
|
+
if _store.kind == "keyring":
|
|
90
|
+
try:
|
|
91
|
+
return token_store.read(_store)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
_fall_back_to_file(exc)
|
|
94
|
+
return token_store.read(_store)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_cache(s: config.Settings) -> msal.SerializableTokenCache:
|
|
98
|
+
"""The msal cache from the selected store; nothing stored, or unreadable, yields an empty one.
|
|
99
|
+
|
|
100
|
+
A keyring store that is empty while the file exists takes the file's content: that is
|
|
101
|
+
the cache an earlier version wrote, and save_cache moves it across.
|
|
102
|
+
"""
|
|
103
|
+
global _store, _migrate_from
|
|
104
|
+
_store = token_store.resolve(s)
|
|
105
|
+
cache = msal.SerializableTokenCache()
|
|
106
|
+
text = _read_store()
|
|
107
|
+
migrating = False
|
|
108
|
+
if text is None and _store.kind == "keyring" and s.token_cache.exists():
|
|
109
|
+
text = token_store.read_file(s.token_cache)
|
|
110
|
+
migrating = text is not None
|
|
111
|
+
if text is None:
|
|
112
|
+
return cache
|
|
113
|
+
try:
|
|
114
|
+
cache.deserialize(text)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
log.debug("could not load the token cache from %s: %s", _store.label, exc)
|
|
117
|
+
return cache
|
|
118
|
+
if migrating:
|
|
119
|
+
_migrate_from = s.token_cache
|
|
120
|
+
cache.has_state_changed = True # deserialize cleared it; the keychain has nothing yet
|
|
121
|
+
return cache
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_cache() -> None:
|
|
125
|
+
"""Write the cache back when msal changed it. Never raises: it runs in a `finally`."""
|
|
126
|
+
global _migrate_from
|
|
127
|
+
cache = _cache
|
|
128
|
+
if cache is None or not cache.has_state_changed:
|
|
129
|
+
return
|
|
130
|
+
store = _store or token_store.resolve(config.settings())
|
|
131
|
+
text = cache.serialize()
|
|
132
|
+
if store.kind == "keyring":
|
|
133
|
+
try:
|
|
134
|
+
token_store.write(store, text)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_fall_back_to_file(exc)
|
|
137
|
+
store = store_info()
|
|
138
|
+
else:
|
|
139
|
+
# Only now is the keychain the sole copy the file may be dropped for.
|
|
140
|
+
if _migrate_from is not None:
|
|
141
|
+
with contextlib.suppress(OSError):
|
|
142
|
+
_migrate_from.unlink()
|
|
143
|
+
_migrate_from = None
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
token_store.write(store, text)
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
log.debug("could not save the token cache: %s", exc)
|
|
149
|
+
# serialize() already cleared the flag; set it again so the next save retries.
|
|
150
|
+
cache.has_state_changed = True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def logout() -> tuple[token_store.Store, bool]:
|
|
154
|
+
"""Drop the stored sign-in from the selected store and any leftover file.
|
|
155
|
+
|
|
156
|
+
Returns the store and whether anything was removed.
|
|
157
|
+
"""
|
|
158
|
+
global _app, _cache, _store, _migrate_from
|
|
159
|
+
_app = None
|
|
160
|
+
_cache = None
|
|
161
|
+
_migrate_from = None
|
|
162
|
+
_store = token_store.resolve(config.settings())
|
|
163
|
+
removed = False
|
|
164
|
+
if _store.kind == "keyring":
|
|
165
|
+
try:
|
|
166
|
+
removed = token_store.clear(_store)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
_fall_back_to_file(exc)
|
|
169
|
+
# A plaintext file must never outlive a logout, whichever store is selected now.
|
|
170
|
+
removed = token_store.clear_file(_store.path) or removed
|
|
171
|
+
store = _store
|
|
172
|
+
_store = None
|
|
173
|
+
return store, removed
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- token acquisition
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _replay_mode(s: config.Settings) -> bool:
|
|
180
|
+
return s.fixture_dir is not None and not s.record
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _all_scopes() -> list[str]:
|
|
184
|
+
"""Every scope this build knows about, in spec order, minus the reserved ones."""
|
|
185
|
+
seen: dict[str, None] = dict.fromkeys(
|
|
186
|
+
config.DEFAULT_SCOPES + config.EXTENDED_EXTRA + config.ON_DEMAND_SCOPES
|
|
187
|
+
)
|
|
188
|
+
seen.update(dict.fromkeys(sorted(DECLARED_SCOPES)))
|
|
189
|
+
return [s for s in seen if s not in config.RESERVED_SCOPES]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def acquire_silent(*, force_refresh: bool = False) -> dict:
|
|
193
|
+
"""Get a token without any user interaction. Raises AuthError when that is impossible."""
|
|
194
|
+
s = config.settings()
|
|
195
|
+
if _replay_mode(s):
|
|
196
|
+
log.debug("token source: fixture")
|
|
197
|
+
return {"access_token": synthetic_token(_all_scopes())}
|
|
198
|
+
a = app()
|
|
199
|
+
accounts = a.get_accounts()
|
|
200
|
+
if not accounts:
|
|
201
|
+
raise AuthError("NOT_LOGGED_IN", "no cached sign-in", hint=errors.HINTS["NOT_LOGGED_IN"])
|
|
202
|
+
# ...with_error, not acquire_token_silent: the plain variant collapses every refresh
|
|
203
|
+
# failure into None, which would make the CONSENT_REQUIRED branch unreachable (spec §4.2).
|
|
204
|
+
result = a.acquire_token_silent_with_error(
|
|
205
|
+
config.msal_scopes(s.scopes), account=accounts[0], force_refresh=force_refresh
|
|
206
|
+
)
|
|
207
|
+
if not result or "error" in result:
|
|
208
|
+
raise classify_msal_error(result, during_login=False)
|
|
209
|
+
log.debug("token source: %s", "refresh" if force_refresh else "cache")
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_access_token(force_refresh: bool = False) -> str:
|
|
214
|
+
"""The token provider handed to GraphClient. Never prompts."""
|
|
215
|
+
return acquire_silent(force_refresh=force_refresh)["access_token"]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cached_access_token() -> str | None:
|
|
219
|
+
"""The newest cached access token for the signed-in account, without any network call."""
|
|
220
|
+
s = config.settings()
|
|
221
|
+
if _replay_mode(s):
|
|
222
|
+
return synthetic_token(_all_scopes())
|
|
223
|
+
a = app()
|
|
224
|
+
accounts = a.get_accounts()
|
|
225
|
+
if not accounts:
|
|
226
|
+
return None
|
|
227
|
+
entries = list(
|
|
228
|
+
a.token_cache.search(
|
|
229
|
+
msal.TokenCache.CredentialType.ACCESS_TOKEN,
|
|
230
|
+
query={"home_account_id": accounts[0]["home_account_id"]},
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
if not entries:
|
|
234
|
+
return None
|
|
235
|
+
newest = max(entries, key=lambda e: int(e.get("expires_on") or 0))
|
|
236
|
+
return newest.get("secret")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def my_oid() -> str:
|
|
240
|
+
"""The signed-in user's object id, read from the cached token (no `/me` call)."""
|
|
241
|
+
oid = decode_jwt(cached_access_token() or "").get("oid")
|
|
242
|
+
if not oid:
|
|
243
|
+
raise AuthError(
|
|
244
|
+
"NOT_LOGGED_IN",
|
|
245
|
+
"the cached token carries no oid claim",
|
|
246
|
+
hint=errors.HINTS["NOT_LOGGED_IN"],
|
|
247
|
+
)
|
|
248
|
+
return oid
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def account_upn() -> str | None:
|
|
252
|
+
s = config.settings()
|
|
253
|
+
if _replay_mode(s):
|
|
254
|
+
return FIXTURE_UPN
|
|
255
|
+
accounts = app().get_accounts()
|
|
256
|
+
return accounts[0].get("username") if accounts else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --------------------------------------------------------------------------- interactive sign-in
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _print_auth_uri(uri: str) -> None:
|
|
263
|
+
sys.stderr.write(f"If no browser opened, visit this URL to sign in:\n{uri}\n")
|
|
264
|
+
sys.stderr.flush()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def login_interactive(scopes: list[str], *, force: bool) -> dict:
|
|
268
|
+
"""Sign in through the system browser on a loopback redirect. Only `login` calls this."""
|
|
269
|
+
try:
|
|
270
|
+
result = app().acquire_token_interactive(
|
|
271
|
+
config.msal_scopes(scopes),
|
|
272
|
+
prompt="login" if force else "select_account",
|
|
273
|
+
port=None,
|
|
274
|
+
timeout=LOGIN_TIMEOUT_SECONDS,
|
|
275
|
+
auth_uri_callback=_print_auth_uri,
|
|
276
|
+
)
|
|
277
|
+
except BrowserInteractionTimeoutError as exc:
|
|
278
|
+
raise AuthError(
|
|
279
|
+
"LOGIN_TIMEOUT",
|
|
280
|
+
f"the browser sign-in did not complete within {LOGIN_TIMEOUT_SECONDS} s",
|
|
281
|
+
hint=f"run '{config.shim_path()} login' again and finish the sign-in in the browser",
|
|
282
|
+
) from exc
|
|
283
|
+
if not result or "error" in result:
|
|
284
|
+
raise classify_msal_error(result, during_login=True)
|
|
285
|
+
log.debug("token source: interactive")
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def login_device_code(scopes: list[str]) -> dict:
|
|
290
|
+
"""Sign in with the device code flow, for hosts with no browser. Only `login` calls this."""
|
|
291
|
+
a = app()
|
|
292
|
+
flow = a.initiate_device_flow(scopes=config.msal_scopes(scopes))
|
|
293
|
+
if "user_code" not in flow:
|
|
294
|
+
raise classify_msal_error(flow, during_login=True)
|
|
295
|
+
sys.stderr.write(f"{flow.get('message', '')}\n")
|
|
296
|
+
sys.stderr.flush()
|
|
297
|
+
result = a.acquire_token_by_device_flow(flow)
|
|
298
|
+
if not result or "error" in result:
|
|
299
|
+
raise classify_msal_error(result, during_login=True)
|
|
300
|
+
log.debug("token source: device code")
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------- claims and scopes
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _b64url(payload: dict) -> str:
|
|
308
|
+
raw = json.dumps(payload, separators=(",", ":")).encode()
|
|
309
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def decode_jwt(token: str) -> dict:
|
|
313
|
+
"""The JWT payload, unverified. An undecodable token yields an empty dict."""
|
|
314
|
+
parts = token.split(".") if token else []
|
|
315
|
+
if len(parts) < 2:
|
|
316
|
+
return {}
|
|
317
|
+
padded = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
318
|
+
try:
|
|
319
|
+
claims = json.loads(base64.urlsafe_b64decode(padded))
|
|
320
|
+
except (ValueError, TypeError) as exc:
|
|
321
|
+
log.debug("could not decode the access token: %s", exc)
|
|
322
|
+
return {}
|
|
323
|
+
return claims if isinstance(claims, dict) else {}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def synthetic_token(scopes: Iterable[str], *, upn: str = FIXTURE_UPN, ttl: int = 3600) -> str:
|
|
327
|
+
"""An unsigned JWT for fixture replay and tests. Never accepted by Graph."""
|
|
328
|
+
now = int(time.time())
|
|
329
|
+
claims = {
|
|
330
|
+
"upn": upn,
|
|
331
|
+
"unique_name": upn,
|
|
332
|
+
"oid": FIXTURE_OID,
|
|
333
|
+
"tid": FIXTURE_TID,
|
|
334
|
+
"scp": " ".join(scopes),
|
|
335
|
+
"iat": now,
|
|
336
|
+
"exp": now + ttl,
|
|
337
|
+
}
|
|
338
|
+
return f"{_b64url({'alg': 'none', 'typ': 'JWT'})}.{_b64url(claims)}."
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def expand_scopes(held: Iterable[str]) -> set[str]:
|
|
342
|
+
"""Held scopes plus everything they imply (spec §4.4)."""
|
|
343
|
+
scopes = set(held)
|
|
344
|
+
for scope in list(scopes):
|
|
345
|
+
scopes.update(config.SCOPE_IMPLIES.get(scope, ()))
|
|
346
|
+
return scopes
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def require_scopes(token: str, declared: list[str]) -> None:
|
|
350
|
+
"""The local gate: raise MISSING_SCOPE before any request. `"A|B"` means any-of."""
|
|
351
|
+
if not declared:
|
|
352
|
+
return
|
|
353
|
+
held = expand_scopes(decode_jwt(token).get("scp", "").split())
|
|
354
|
+
for entry in declared:
|
|
355
|
+
options = entry.split("|")
|
|
356
|
+
if any(option in held for option in options):
|
|
357
|
+
continue
|
|
358
|
+
wanted = options[0]
|
|
359
|
+
family = wanted.split(".")[0]
|
|
360
|
+
closest = max((h for h in held if h.split(".")[0] == family), key=len, default=None)
|
|
361
|
+
if wanted in config.ON_DEMAND_SCOPES:
|
|
362
|
+
hint = f"run '{config.shim_path()} login --scope {wanted}' in your own terminal"
|
|
363
|
+
else:
|
|
364
|
+
hint = errors.HINTS["MISSING_SCOPE"]
|
|
365
|
+
raise AuthError(
|
|
366
|
+
"MISSING_SCOPE",
|
|
367
|
+
f"this command needs {' or '.join(options)}; the current token has {closest or 'none'}",
|
|
368
|
+
hint=hint,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --------------------------------------------------------------------------- error classification
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def admin_consent_url() -> str:
|
|
376
|
+
s = config.settings()
|
|
377
|
+
token = cached_access_token()
|
|
378
|
+
tenant = (decode_jwt(token).get("tid") if token else None) or s.tenant_id
|
|
379
|
+
return f"https://{config.TOKEN_HOST}/{tenant}/adminconsent?client_id={s.client_id}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def classify_msal_error(result: dict | None, *, during_login: bool) -> AuthError:
|
|
383
|
+
"""Turn an msal failure into CONSENT_REQUIRED or NOT_LOGGED_IN (spec §4.5)."""
|
|
384
|
+
result = result or {}
|
|
385
|
+
desc = result.get("error_description") or ""
|
|
386
|
+
first = desc.splitlines()[0] if desc else (result.get("error") or "no cached sign-in")
|
|
387
|
+
if result.get("error") == "consent_required" or any(m in desc for m in CONSENT_MARKERS):
|
|
388
|
+
return AuthError(
|
|
389
|
+
"CONSENT_REQUIRED",
|
|
390
|
+
first,
|
|
391
|
+
hint=f"an admin must grant consent once: {admin_consent_url()}",
|
|
392
|
+
correlation_id=result.get("correlation_id"),
|
|
393
|
+
)
|
|
394
|
+
hint = errors.HINTS["UNAUTHORIZED"] if during_login else errors.HINTS["NOT_LOGGED_IN"]
|
|
395
|
+
return AuthError("NOT_LOGGED_IN", first, hint=hint, correlation_id=result.get("correlation_id"))
|
mgraphctl/cli.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Root typer app, global options and the `@graph_command` decorator (spec §6.1, §6.5, §7.1).
|
|
2
|
+
|
|
3
|
+
`from __future__ import annotations` must NOT be added here or to any `commands/*` module:
|
|
4
|
+
typer reads the real `Annotated` objects off each command signature.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any, TypeVar
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
import typer.core
|
|
20
|
+
|
|
21
|
+
from mgraphctl import __version__, auth, config, fixtures, render
|
|
22
|
+
from mgraphctl.errors import MsgraphError, UsageError, format_error
|
|
23
|
+
from mgraphctl.http import GraphClient
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("mgraphctl")
|
|
26
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
27
|
+
|
|
28
|
+
# Exceptions typer and Click use for control flow; they must reach Click's own handler.
|
|
29
|
+
CONTROL_FLOW = (typer.Exit, typer.Abort, typer.TyperException)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Globals:
|
|
34
|
+
"""The root callback's decisions, handed to every command through `ctx.obj`."""
|
|
35
|
+
|
|
36
|
+
debug: int
|
|
37
|
+
tz: str
|
|
38
|
+
beta: bool
|
|
39
|
+
# Config keys a root flag overrode (`tz`, `debug`), so `config show` can say so.
|
|
40
|
+
flag_keys: frozenset[str] = frozenset()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
JsonFlag = Annotated[bool, typer.Option("--json", help="Print JSON instead of text.")]
|
|
44
|
+
DryRunFlag = Annotated[bool, typer.Option("--dry-run", help="Show the request(s); send nothing.")]
|
|
45
|
+
LimitOpt = Annotated[int | None, typer.Option("--limit", min=1, help="Maximum items.")]
|
|
46
|
+
AllFlag = Annotated[bool, typer.Option("--all", help="Fetch every page up to the cap.")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MsgraphGroup(typer.core.TyperGroup):
|
|
50
|
+
"""Maps every failure to the §6.5 stderr block plus an exit code, and flushes the cache."""
|
|
51
|
+
|
|
52
|
+
def invoke(self, ctx: typer.Context) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
return super().invoke(ctx)
|
|
55
|
+
except MsgraphError as exc:
|
|
56
|
+
self._fail(ctx, format_error(exc), exc.exit_code)
|
|
57
|
+
except CONTROL_FLOW:
|
|
58
|
+
raise # --help, --version, Click parse errors: Click renders and exits on its own.
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
self._fail(ctx, format_error(MsgraphError("IO", _io_detail(exc))), 1)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
block = format_error(MsgraphError("INTERNAL", f"{type(exc).__name__}: {exc}"))
|
|
63
|
+
if _debug_level(ctx) >= 1:
|
|
64
|
+
block += "".join(traceback.format_exception(exc))
|
|
65
|
+
self._fail(ctx, block, 1)
|
|
66
|
+
finally:
|
|
67
|
+
auth.save_cache()
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _fail(ctx: typer.Context, block: str, exit_code: int) -> None:
|
|
71
|
+
sys.stdout.flush()
|
|
72
|
+
sys.stderr.write(block)
|
|
73
|
+
ctx.exit(exit_code)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _io_detail(exc: OSError) -> str:
|
|
77
|
+
detail = exc.strerror or str(exc)
|
|
78
|
+
return f"{detail}: {exc.filename}" if exc.filename else detail
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _debug_level(ctx: typer.Context) -> int:
|
|
82
|
+
"""The `--debug` count, or 0 when the root callback has not run yet."""
|
|
83
|
+
g = ctx.find_root().obj
|
|
84
|
+
return g.debug if isinstance(g, Globals) else 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _noargs_help(ctx: typer.Context) -> None:
|
|
88
|
+
"""No verb at this level prints its help and exits 0 (§6.1; `no_args_is_help` exits 2)."""
|
|
89
|
+
if ctx.invoked_subcommand is None:
|
|
90
|
+
typer.echo(ctx.get_help())
|
|
91
|
+
raise typer.Exit(0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_noun_app(help: str) -> typer.Typer: # noqa: A002 - name fixed by the Phase 0 contract
|
|
95
|
+
"""A noun sub-app whose bare invocation prints help instead of failing."""
|
|
96
|
+
return typer.Typer(
|
|
97
|
+
help=help,
|
|
98
|
+
invoke_without_command=True,
|
|
99
|
+
callback=_noargs_help,
|
|
100
|
+
add_completion=False,
|
|
101
|
+
rich_markup_mode=None,
|
|
102
|
+
pretty_exceptions_enable=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def open_client(g: Globals, scopes: list[str]) -> GraphClient:
|
|
107
|
+
"""Run the local scope gate, then build the client every command shares."""
|
|
108
|
+
token = auth.get_access_token(False)
|
|
109
|
+
auth.require_scopes(token, scopes)
|
|
110
|
+
cfg = config.settings()
|
|
111
|
+
return GraphClient(
|
|
112
|
+
auth.get_access_token,
|
|
113
|
+
tz=g.tz,
|
|
114
|
+
beta=g.beta,
|
|
115
|
+
debug=g.debug,
|
|
116
|
+
transport=fixtures.transport_from_env(cfg),
|
|
117
|
+
retries=cfg.retries,
|
|
118
|
+
timeout_ms=cfg.timeout_ms,
|
|
119
|
+
retry_base_ms=cfg.retry_base_ms,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gate(scopes: list[str]) -> None:
|
|
124
|
+
"""Re-check scopes at a branch that needs more than the command declared (§4.4)."""
|
|
125
|
+
auth.require_scopes(auth.get_access_token(False), scopes)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def page_bounds(limit: int | None, all_: bool, *, default: int) -> tuple[int, bool]:
|
|
129
|
+
"""Normalise the `--limit` / `--all` pair for a list verb."""
|
|
130
|
+
if all_ and limit is not None:
|
|
131
|
+
raise UsageError("USAGE", "--all and --limit are mutually exclusive")
|
|
132
|
+
return (limit if limit is not None else default), all_
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_body(body: str | None, body_file: str | None) -> str:
|
|
136
|
+
"""The message body from `--body`, `--body-file FILE`, or `--body-file -` (stdin)."""
|
|
137
|
+
if (body is None) == (body_file is None):
|
|
138
|
+
raise UsageError("USAGE", "give exactly one of --body or --body-file")
|
|
139
|
+
if body is not None:
|
|
140
|
+
return body
|
|
141
|
+
return sys.stdin.read() if body_file == "-" else Path(body_file).read_text()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def graph_command(*, scopes: list[str]) -> Callable[[F], F]:
|
|
145
|
+
"""Gate the scopes, open a client, inject it as `client`, and render what the verb returns."""
|
|
146
|
+
auth.DECLARED_SCOPES.update(s for entry in scopes for s in entry.split("|"))
|
|
147
|
+
|
|
148
|
+
def decorate(fn: F) -> F:
|
|
149
|
+
sig = inspect.signature(fn)
|
|
150
|
+
params = [p for p in sig.parameters.values() if p.name != "client"]
|
|
151
|
+
ctx_param = inspect.Parameter(
|
|
152
|
+
"ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=typer.Context
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@functools.wraps(fn)
|
|
156
|
+
def wrapper(ctx: typer.Context, **kwargs: Any) -> None:
|
|
157
|
+
g: Globals = ctx.find_root().obj
|
|
158
|
+
with open_client(g, scopes) as client:
|
|
159
|
+
result = fn(client, **kwargs)
|
|
160
|
+
if result is not None:
|
|
161
|
+
render.emit(result, json_mode=bool(kwargs.get("json_", False)))
|
|
162
|
+
|
|
163
|
+
# typer reads the signature and the annotations; `client` is ours, not the CLI's.
|
|
164
|
+
wrapper.__signature__ = sig.replace(parameters=[ctx_param, *params])
|
|
165
|
+
annotations = dict(fn.__annotations__)
|
|
166
|
+
annotations.pop("client", None)
|
|
167
|
+
annotations["ctx"] = typer.Context
|
|
168
|
+
wrapper.__annotations__ = annotations
|
|
169
|
+
wrapper.__graph_scopes__ = list(scopes)
|
|
170
|
+
return wrapper # type: ignore[return-value]
|
|
171
|
+
|
|
172
|
+
return decorate
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _version_cb(value: bool) -> None:
|
|
176
|
+
if value:
|
|
177
|
+
typer.echo(f"mgraphctl {__version__}")
|
|
178
|
+
raise typer.Exit(0)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _configure_logging(level: int) -> None:
|
|
182
|
+
"""Bind logging to the current stderr and prefix every line with its level (§5.7)."""
|
|
183
|
+
logging.basicConfig(
|
|
184
|
+
stream=sys.stderr,
|
|
185
|
+
level=logging.DEBUG if level else logging.WARNING,
|
|
186
|
+
format="%(levelname)s %(message)s",
|
|
187
|
+
force=True,
|
|
188
|
+
)
|
|
189
|
+
logging.getLogger("msal").setLevel(logging.INFO if level else logging.WARNING)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_app() -> typer.Typer:
|
|
193
|
+
"""The whole CLI: global options, the top-level verbs, `api`, and every noun that exists."""
|
|
194
|
+
root = typer.Typer(
|
|
195
|
+
cls=MsgraphGroup,
|
|
196
|
+
invoke_without_command=True,
|
|
197
|
+
add_completion=False,
|
|
198
|
+
rich_markup_mode=None,
|
|
199
|
+
pretty_exceptions_enable=False,
|
|
200
|
+
help="Microsoft Graph from the command line.",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@root.callback()
|
|
204
|
+
def _root(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
debug: Annotated[
|
|
207
|
+
int,
|
|
208
|
+
typer.Option(
|
|
209
|
+
"--debug", "-d", count=True, help="Log requests to stderr (-dd adds bodies)."
|
|
210
|
+
),
|
|
211
|
+
] = 0,
|
|
212
|
+
tz: Annotated[
|
|
213
|
+
str | None,
|
|
214
|
+
typer.Option("--tz", help="IANA time zone (else MGRAPHCTL_TZ, the config file)."),
|
|
215
|
+
] = None,
|
|
216
|
+
beta: Annotated[bool, typer.Option("--beta", help="Use the /beta endpoint.")] = False,
|
|
217
|
+
config_file: Annotated[
|
|
218
|
+
Path | None,
|
|
219
|
+
typer.Option(
|
|
220
|
+
"--config",
|
|
221
|
+
help="Config file (else MGRAPHCTL_CONFIG, ~/.mgraphctl/config.toml).",
|
|
222
|
+
),
|
|
223
|
+
] = None,
|
|
224
|
+
version: Annotated[
|
|
225
|
+
bool,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--version",
|
|
228
|
+
callback=_version_cb,
|
|
229
|
+
is_eager=True,
|
|
230
|
+
help="Print the version and exit.",
|
|
231
|
+
),
|
|
232
|
+
] = False,
|
|
233
|
+
) -> None:
|
|
234
|
+
if config_file is not None:
|
|
235
|
+
# `settings()` reads the environment, so the flag becomes the env var for this run.
|
|
236
|
+
os.environ["MGRAPHCTL_CONFIG"] = str(config_file)
|
|
237
|
+
s = config.settings()
|
|
238
|
+
zone = tz or s.tz or render.local_tz()
|
|
239
|
+
if not render.is_iana(zone):
|
|
240
|
+
raise UsageError(
|
|
241
|
+
"USAGE", f"unknown time zone {zone!r}; use an IANA name such as Europe/Warsaw"
|
|
242
|
+
)
|
|
243
|
+
level = max(debug, s.debug)
|
|
244
|
+
_configure_logging(level)
|
|
245
|
+
flag_keys = {k for k, hit in (("tz", tz), ("debug", debug and debug >= s.debug)) if hit}
|
|
246
|
+
ctx.obj = Globals(debug=level, tz=zone, beta=beta, flag_keys=frozenset(flag_keys))
|
|
247
|
+
_noargs_help(ctx)
|
|
248
|
+
|
|
249
|
+
from mgraphctl.commands import api, config_cmd, register_all, top
|
|
250
|
+
|
|
251
|
+
top.register(root)
|
|
252
|
+
api.register(root)
|
|
253
|
+
config_cmd.register(root)
|
|
254
|
+
register_all(root)
|
|
255
|
+
return root
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main() -> None:
|
|
259
|
+
build_app()(prog_name="mgraphctl")
|