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
mgraphctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mgraphctl: Microsoft Graph CLI for the mgraphctl skill."""
2
+
3
+ __version__ = "0.1.0"
mgraphctl/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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")