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/render.py ADDED
@@ -0,0 +1,627 @@
1
+ """Result rendering, datetime helpers and the Windows→IANA timezone table.
2
+
3
+ Spec §6.2, §6.3, §7.3.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
20
+
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+
24
+ from mgraphctl import errors
25
+
26
+ # CLDR windowsZones.xml territory="001" mappings, generated 2026-09-02; regenerate with the
27
+ # script in the plan.
28
+ WINDOWS_TO_IANA: dict[str, str] = {
29
+ "AUS Central Standard Time": "Australia/Darwin",
30
+ "AUS Eastern Standard Time": "Australia/Sydney",
31
+ "Afghanistan Standard Time": "Asia/Kabul",
32
+ "Alaskan Standard Time": "America/Anchorage",
33
+ "Aleutian Standard Time": "America/Adak",
34
+ "Altai Standard Time": "Asia/Barnaul",
35
+ "Arab Standard Time": "Asia/Riyadh",
36
+ "Arabian Standard Time": "Asia/Dubai",
37
+ "Arabic Standard Time": "Asia/Baghdad",
38
+ "Argentina Standard Time": "America/Buenos_Aires",
39
+ "Astrakhan Standard Time": "Europe/Astrakhan",
40
+ "Atlantic Standard Time": "America/Halifax",
41
+ "Aus Central W. Standard Time": "Australia/Eucla",
42
+ "Azerbaijan Standard Time": "Asia/Baku",
43
+ "Azores Standard Time": "Atlantic/Azores",
44
+ "Bahia Standard Time": "America/Bahia",
45
+ "Bangladesh Standard Time": "Asia/Dhaka",
46
+ "Belarus Standard Time": "Europe/Minsk",
47
+ "Bougainville Standard Time": "Pacific/Bougainville",
48
+ "Canada Central Standard Time": "America/Regina",
49
+ "Cape Verde Standard Time": "Atlantic/Cape_Verde",
50
+ "Caucasus Standard Time": "Asia/Yerevan",
51
+ "Cen. Australia Standard Time": "Australia/Adelaide",
52
+ "Central America Standard Time": "America/Guatemala",
53
+ "Central Asia Standard Time": "Asia/Bishkek",
54
+ "Central Brazilian Standard Time": "America/Cuiaba",
55
+ "Central Europe Standard Time": "Europe/Budapest",
56
+ "Central European Standard Time": "Europe/Warsaw",
57
+ "Central Pacific Standard Time": "Pacific/Guadalcanal",
58
+ "Central Standard Time": "America/Chicago",
59
+ "Central Standard Time (Mexico)": "America/Mexico_City",
60
+ "Chatham Islands Standard Time": "Pacific/Chatham",
61
+ "China Standard Time": "Asia/Shanghai",
62
+ "Cuba Standard Time": "America/Havana",
63
+ "Dateline Standard Time": "Etc/GMT+12",
64
+ "E. Africa Standard Time": "Africa/Nairobi",
65
+ "E. Australia Standard Time": "Australia/Brisbane",
66
+ "E. Europe Standard Time": "Europe/Chisinau",
67
+ "E. South America Standard Time": "America/Sao_Paulo",
68
+ "Easter Island Standard Time": "Pacific/Easter",
69
+ "Eastern Standard Time": "America/New_York",
70
+ "Eastern Standard Time (Mexico)": "America/Cancun",
71
+ "Egypt Standard Time": "Africa/Cairo",
72
+ "Ekaterinburg Standard Time": "Asia/Yekaterinburg",
73
+ "FLE Standard Time": "Europe/Kiev",
74
+ "Fiji Standard Time": "Pacific/Fiji",
75
+ "GMT Standard Time": "Europe/London",
76
+ "GTB Standard Time": "Europe/Bucharest",
77
+ "Georgian Standard Time": "Asia/Tbilisi",
78
+ "Greenland Standard Time": "America/Godthab",
79
+ "Greenwich Standard Time": "Atlantic/Reykjavik",
80
+ "Haiti Standard Time": "America/Port-au-Prince",
81
+ "Hawaiian Standard Time": "Pacific/Honolulu",
82
+ "India Standard Time": "Asia/Calcutta",
83
+ "Iran Standard Time": "Asia/Tehran",
84
+ "Israel Standard Time": "Asia/Jerusalem",
85
+ "Jordan Standard Time": "Asia/Amman",
86
+ "Kaliningrad Standard Time": "Europe/Kaliningrad",
87
+ "Korea Standard Time": "Asia/Seoul",
88
+ "Libya Standard Time": "Africa/Tripoli",
89
+ "Line Islands Standard Time": "Pacific/Kiritimati",
90
+ "Lord Howe Standard Time": "Australia/Lord_Howe",
91
+ "Magadan Standard Time": "Asia/Magadan",
92
+ "Magallanes Standard Time": "America/Punta_Arenas",
93
+ "Marquesas Standard Time": "Pacific/Marquesas",
94
+ "Mauritius Standard Time": "Indian/Mauritius",
95
+ "Middle East Standard Time": "Asia/Beirut",
96
+ "Montevideo Standard Time": "America/Montevideo",
97
+ "Morocco Standard Time": "Africa/Casablanca",
98
+ "Mountain Standard Time": "America/Denver",
99
+ "Mountain Standard Time (Mexico)": "America/Mazatlan",
100
+ "Myanmar Standard Time": "Asia/Rangoon",
101
+ "N. Central Asia Standard Time": "Asia/Novosibirsk",
102
+ "Namibia Standard Time": "Africa/Windhoek",
103
+ "Nepal Standard Time": "Asia/Katmandu",
104
+ "New Zealand Standard Time": "Pacific/Auckland",
105
+ "Newfoundland Standard Time": "America/St_Johns",
106
+ "Norfolk Standard Time": "Pacific/Norfolk",
107
+ "North Asia East Standard Time": "Asia/Irkutsk",
108
+ "North Asia Standard Time": "Asia/Krasnoyarsk",
109
+ "North Korea Standard Time": "Asia/Pyongyang",
110
+ "Omsk Standard Time": "Asia/Omsk",
111
+ "Pacific SA Standard Time": "America/Santiago",
112
+ "Pacific Standard Time": "America/Los_Angeles",
113
+ "Pacific Standard Time (Mexico)": "America/Tijuana",
114
+ "Pakistan Standard Time": "Asia/Karachi",
115
+ "Paraguay Standard Time": "America/Asuncion",
116
+ "Qyzylorda Standard Time": "Asia/Qyzylorda",
117
+ "Romance Standard Time": "Europe/Paris",
118
+ "Russia Time Zone 10": "Asia/Srednekolymsk",
119
+ "Russia Time Zone 11": "Asia/Kamchatka",
120
+ "Russia Time Zone 3": "Europe/Samara",
121
+ "Russian Standard Time": "Europe/Moscow",
122
+ "SA Eastern Standard Time": "America/Cayenne",
123
+ "SA Pacific Standard Time": "America/Bogota",
124
+ "SA Western Standard Time": "America/La_Paz",
125
+ "SE Asia Standard Time": "Asia/Bangkok",
126
+ "Saint Pierre Standard Time": "America/Miquelon",
127
+ "Sakhalin Standard Time": "Asia/Sakhalin",
128
+ "Samoa Standard Time": "Pacific/Apia",
129
+ "Sao Tome Standard Time": "Africa/Sao_Tome",
130
+ "Saratov Standard Time": "Europe/Saratov",
131
+ "Singapore Standard Time": "Asia/Singapore",
132
+ "South Africa Standard Time": "Africa/Johannesburg",
133
+ "South Sudan Standard Time": "Africa/Juba",
134
+ "Sri Lanka Standard Time": "Asia/Colombo",
135
+ "Sudan Standard Time": "Africa/Khartoum",
136
+ "Syria Standard Time": "Asia/Damascus",
137
+ "Taipei Standard Time": "Asia/Taipei",
138
+ "Tasmania Standard Time": "Australia/Hobart",
139
+ "Tocantins Standard Time": "America/Araguaina",
140
+ "Tokyo Standard Time": "Asia/Tokyo",
141
+ "Tomsk Standard Time": "Asia/Tomsk",
142
+ "Tonga Standard Time": "Pacific/Tongatapu",
143
+ "Transbaikal Standard Time": "Asia/Chita",
144
+ "Turkey Standard Time": "Europe/Istanbul",
145
+ "Turks And Caicos Standard Time": "America/Grand_Turk",
146
+ "US Eastern Standard Time": "America/Indianapolis",
147
+ "US Mountain Standard Time": "America/Phoenix",
148
+ "UTC": "Etc/UTC",
149
+ "UTC+12": "Etc/GMT-12",
150
+ "UTC+13": "Etc/GMT-13",
151
+ "UTC-02": "Etc/GMT+2",
152
+ "UTC-08": "Etc/GMT+8",
153
+ "UTC-09": "Etc/GMT+9",
154
+ "UTC-11": "Etc/GMT+11",
155
+ "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
156
+ "Venezuela Standard Time": "America/Caracas",
157
+ "Vladivostok Standard Time": "Asia/Vladivostok",
158
+ "Volgograd Standard Time": "Europe/Volgograd",
159
+ "W. Australia Standard Time": "Australia/Perth",
160
+ "W. Central Africa Standard Time": "Africa/Lagos",
161
+ "W. Europe Standard Time": "Europe/Berlin",
162
+ "W. Mongolia Standard Time": "Asia/Hovd",
163
+ "West Asia Standard Time": "Asia/Tashkent",
164
+ "West Bank Standard Time": "Asia/Hebron",
165
+ "West Pacific Standard Time": "Pacific/Port_Moresby",
166
+ "Yakutsk Standard Time": "Asia/Yakutsk",
167
+ "Yukon Standard Time": "America/Whitehorse",
168
+ }
169
+
170
+
171
+ @dataclass
172
+ class Column:
173
+ header: str
174
+ path: str | Callable[[dict], Any]
175
+ width: int | None = None
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class Window:
180
+ """The time window a listing was asked for, whatever the command calls its options."""
181
+
182
+ after: datetime | None = None
183
+ before: datetime | None = None
184
+
185
+ def to_json(self) -> dict[str, str | None]:
186
+ return {
187
+ "after": to_iso_offset(self.after) if self.after else None,
188
+ "before": to_iso_offset(self.before) if self.before else None,
189
+ }
190
+
191
+
192
+ @dataclass
193
+ class ListResult:
194
+ items: list[dict]
195
+ columns: list[Column]
196
+ truncated: bool = False
197
+ empty_text: str = "No results."
198
+ hit_cap: int | None = None
199
+ # False on verbs with no `--all`, so the truncation note never suggests a flag they lack.
200
+ supports_all: bool = True
201
+ extra: dict | None = None
202
+ # The fetch behind `items`. Given it, `truncated`, `cap` and `fetched` come from one place
203
+ # rather than being restated — and stay describing the fetch even when `items` was filtered
204
+ # or reordered afterwards.
205
+ page: Any = None # mgraphctl.http.PageResult; not imported, to keep this module leaf-level
206
+ cap: int | None = None
207
+ fetched: int | None = None
208
+ # Present on the commands that accept a time window, even when both bounds are unset.
209
+ window: Window | None = None
210
+ query: dict[str, str] | None = None
211
+
212
+ def __post_init__(self) -> None:
213
+ # Given a page, it is the authority on the fetch — including `truncated`, which is why
214
+ # it overrides rather than defaults. A caller that filtered or reordered `items` must
215
+ # not be able to talk the envelope into describing that instead.
216
+ if self.page is None:
217
+ return
218
+ self.truncated = self.page.truncated
219
+ if self.cap is None:
220
+ self.cap = self.page.cap
221
+ if self.fetched is None:
222
+ self.fetched = self.page.fetched_count
223
+ if self.query is None:
224
+ self.query = self.page.query
225
+
226
+
227
+ @dataclass
228
+ class ObjectResult:
229
+ obj: dict
230
+ fields: list[tuple[str, str | Callable[[dict], Any]]]
231
+ body: str | None = None
232
+
233
+
234
+ @dataclass
235
+ class TextResult:
236
+ text: str
237
+ json_obj: Any
238
+
239
+
240
+ @dataclass
241
+ class WriteResult:
242
+ obj: dict | None
243
+ message: str
244
+
245
+
246
+ @dataclass
247
+ class DryRunResult:
248
+ # `plan` is `mgraphctl.http.Plan` (list[PlannedRequest]); not imported at module level so
249
+ # this module can be implemented and tested in parallel with T3's http.py.
250
+ plan: Any
251
+
252
+
253
+ @dataclass
254
+ class FileResult:
255
+ path: Path
256
+ bytes: int
257
+ meta: dict
258
+ message: str
259
+
260
+
261
+ Result = ListResult | ObjectResult | TextResult | WriteResult | DryRunResult | FileResult
262
+
263
+ _FRACTIONAL_RE = re.compile(r"(\.\d{6})\d+")
264
+ _RELATIVE_RE = re.compile(r"^([+-])(\d+)([dh])$")
265
+ _DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
266
+ _SIMPLE_DURATION_RE = re.compile(r"^(\d+)([mhd])$")
267
+ _ISO_TIME_DURATION_RE = re.compile(r"^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$")
268
+ _ISO_DAY_DURATION_RE = re.compile(r"^P(\d+)D$")
269
+
270
+ _tz_fallback_warned = False
271
+
272
+
273
+ def note(text: str) -> None:
274
+ """Write a diagnostic line to stderr (pagination notes, warnings)."""
275
+ sys.stderr.write(text + "\n")
276
+
277
+
278
+ def dig(obj: Any, path: str) -> Any:
279
+ """Dotted-path lookup into nested dicts, returning None on any missing/None segment."""
280
+ current = obj
281
+ for part in path.split("."):
282
+ if current is None:
283
+ return None
284
+ current = current.get(part) if isinstance(current, dict) else getattr(current, part, None)
285
+ return current
286
+
287
+
288
+ def truncate(text: str | None, n: int = 60) -> str:
289
+ """Cap free text at `n` characters, appending an ellipsis when it was longer."""
290
+ if text is None:
291
+ return ""
292
+ if len(text) > n:
293
+ return text[: n - 1] + "…"
294
+ return text
295
+
296
+
297
+ @functools.cache
298
+ def is_iana(name: str) -> bool:
299
+ """Whether `name` is a resolvable IANA zone key."""
300
+ try:
301
+ ZoneInfo(name)
302
+ return True
303
+ except (ZoneInfoNotFoundError, ValueError, IsADirectoryError):
304
+ return False
305
+
306
+
307
+ def local_tz() -> str:
308
+ """Detect the local IANA timezone (spec §6.3), warning once on the UTC fallback."""
309
+ global _tz_fallback_warned
310
+ env = os.environ
311
+ for candidate in (env.get("MGRAPHCTL_TZ"), env.get("TZ")):
312
+ if candidate and is_iana(candidate):
313
+ return candidate
314
+ try:
315
+ link = os.readlink("/etc/localtime")
316
+ except OSError:
317
+ link = None
318
+ if link:
319
+ marker = "zoneinfo/"
320
+ idx = link.rfind(marker)
321
+ if idx != -1:
322
+ candidate = link[idx + len(marker) :]
323
+ if is_iana(candidate):
324
+ return candidate
325
+ if sys.platform == "win32":
326
+ candidate = WINDOWS_TO_IANA.get(time.tzname[0])
327
+ if candidate:
328
+ return candidate
329
+ if not _tz_fallback_warned:
330
+ note("warning: could not detect the local time zone; using UTC (set MGRAPHCTL_TZ)")
331
+ _tz_fallback_warned = True
332
+ return "UTC"
333
+
334
+
335
+ def parse_graph_dt(value: str | None) -> datetime | None:
336
+ """A Graph timestamp as an aware datetime (bare ones are UTC), or None when unusable."""
337
+ if not value:
338
+ return None
339
+ text = _FRACTIONAL_RE.sub(r"\1", value)
340
+ if text.endswith("Z"):
341
+ text = text[:-1] + "+00:00"
342
+ try:
343
+ dt = datetime.fromisoformat(text)
344
+ except ValueError:
345
+ return None
346
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
347
+
348
+
349
+ def fmt_dt(value: str | None, tz: str) -> str:
350
+ """Render an ISO datetime string (`Z` or offset) in `tz`, or "N/A" when there is none."""
351
+ dt = parse_graph_dt(value)
352
+ if dt is None:
353
+ return "N/A"
354
+ return dt.astimezone(ZoneInfo(tz)).isoformat(timespec="minutes")
355
+
356
+
357
+ def fmt_dtz(obj: dict | None, tz: str) -> str:
358
+ """Render a Graph `dateTimeTimeZone` object in `tz`, or verbatim for a Windows zone name.
359
+
360
+ An absent or empty `timeZone` means UTC, which is what Graph documents as the default when
361
+ no `Prefer: outlook.timezone` was sent — reachable through `getSchedule`, search event hits
362
+ and the mailbox out-of-office block, none of which send that header.
363
+ """
364
+ if obj is None:
365
+ return "N/A"
366
+ date_time = obj.get("dateTime")
367
+ zone = obj.get("timeZone") or "UTC"
368
+ if date_time is None:
369
+ return "N/A"
370
+ if zone == "UTC" or is_iana(zone):
371
+ v = _FRACTIONAL_RE.sub(r"\1", date_time)
372
+ dt = datetime.fromisoformat(v).replace(tzinfo=ZoneInfo(zone))
373
+ return dt.astimezone(ZoneInfo(tz)).isoformat(timespec="minutes")
374
+ return f"{date_time} ({zone})"
375
+
376
+
377
+ def fmt_event_time(event: dict, key: str, tz: str) -> str:
378
+ """Render an event's start/end: all-day events as `YYYY-MM-DD (all day)`."""
379
+ if event.get("isAllDay"):
380
+ return f"{event[key]['dateTime'][:10]} (all day)"
381
+ return fmt_dtz(event[key], tz)
382
+
383
+
384
+ def fmt_date(value: str | None) -> str:
385
+ """Render a date-only value: the `YYYY-MM-DD` prefix of a date or datetime string, or "N/A"."""
386
+ if not value:
387
+ return "N/A"
388
+ return value[:10]
389
+
390
+
391
+ def fmt_size(n: int | None) -> str:
392
+ """Render a byte count as `B`/`KB`/`MB`/`GB` with one decimal above bytes."""
393
+ if n is None:
394
+ return ""
395
+ if n < 1024:
396
+ return f"{n} B"
397
+ value = float(n)
398
+ for unit in ("KB", "MB", "GB"):
399
+ value /= 1024
400
+ if value < 1024 or unit == "GB":
401
+ return f"{value:.1f} {unit}"
402
+ return f"{value:.1f} GB"
403
+
404
+
405
+ def fmt_person(obj: dict | None) -> str:
406
+ """Render a Graph recipient/organizer object as `Name <address>`, address alone, or ""."""
407
+ if not obj:
408
+ return ""
409
+ email = obj.get("emailAddress") or {}
410
+ name = email.get("name")
411
+ address = email.get("address")
412
+ if name and address:
413
+ return f"{name} <{address}>"
414
+ return address or ""
415
+
416
+
417
+ def _now(zone: ZoneInfo) -> datetime: # patched by tests
418
+ return datetime.now(zone)
419
+
420
+
421
+ def _day(d: date, zone: ZoneInfo, end_of_day: bool) -> datetime:
422
+ if end_of_day:
423
+ return datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=zone)
424
+ return datetime(d.year, d.month, d.day, 0, 0, tzinfo=zone)
425
+
426
+
427
+ def parse_dt(value: str, tz: str, *, end_of_day: bool = False) -> datetime:
428
+ """Parse a datetime option value (spec §6.3): ISO forms, keywords, or `±Nd`/`±Nh`."""
429
+ zone = ZoneInfo(tz)
430
+ v = value.strip()
431
+ lowered = v.lower()
432
+ if lowered == "now":
433
+ return _now(zone)
434
+ if lowered in ("today", "tomorrow", "yesterday"):
435
+ offset = {"today": 0, "tomorrow": 1, "yesterday": -1}[lowered]
436
+ target = _now(zone).date() + timedelta(days=offset)
437
+ return _day(target, zone, end_of_day)
438
+ m = _RELATIVE_RE.match(v)
439
+ if m:
440
+ sign, amount, unit = m.groups()
441
+ n = int(amount) * (1 if sign == "+" else -1)
442
+ delta = timedelta(days=n) if unit == "d" else timedelta(hours=n)
443
+ return _now(zone) + delta
444
+ if _DATE_ONLY_RE.match(v):
445
+ return _day(date.fromisoformat(v), zone, end_of_day)
446
+ iso = v[:-1] + "+00:00" if v.endswith("Z") else v
447
+ try:
448
+ dt = datetime.fromisoformat(iso)
449
+ except ValueError as exc:
450
+ raise errors.UsageError(
451
+ "USAGE",
452
+ f"invalid datetime {value!r}: use YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS][Z|±HH:MM], "
453
+ "now, today, tomorrow, yesterday, +Nd, -Nd, +Nh",
454
+ ) from exc
455
+ if dt.tzinfo is None:
456
+ dt = dt.replace(tzinfo=zone)
457
+ return dt
458
+
459
+
460
+ def parse_duration(value: str) -> timedelta:
461
+ """Parse a duration option value: `30m`, `2h`, `1d`, or ISO 8601 `PT30M`/`P1D`."""
462
+ v = value.strip()
463
+ m = _SIMPLE_DURATION_RE.match(v)
464
+ if m:
465
+ amount, unit = m.groups()
466
+ n = int(amount)
467
+ return {"m": timedelta(minutes=n), "h": timedelta(hours=n), "d": timedelta(days=n)}[unit]
468
+ m = _ISO_TIME_DURATION_RE.match(v)
469
+ if m and any(m.groups()):
470
+ h, mi, s = (int(g) if g else 0 for g in m.groups())
471
+ return timedelta(hours=h, minutes=mi, seconds=s)
472
+ m = _ISO_DAY_DURATION_RE.match(v)
473
+ if m:
474
+ return timedelta(days=int(m.group(1)))
475
+ raise errors.UsageError(
476
+ "USAGE", f"invalid duration {value!r}: use 30m, 2h, 1d or ISO 8601 PT30M"
477
+ )
478
+
479
+
480
+ def iso_duration(td: timedelta) -> str:
481
+ """Render a timedelta as ISO 8601 `PT#H#M`, omitting zero parts (`PT0M` for zero)."""
482
+ total_minutes = int(td.total_seconds() // 60)
483
+ hours, minutes = divmod(total_minutes, 60)
484
+ if not hours and not minutes:
485
+ return "PT0M"
486
+ parts = []
487
+ if hours:
488
+ parts.append(f"{hours}H")
489
+ if minutes or not hours:
490
+ parts.append(f"{minutes}M")
491
+ return "PT" + "".join(parts)
492
+
493
+
494
+ def to_graph_dtz(dt: datetime, tz: str) -> dict:
495
+ """Render `dt` as a Graph `{"dateTime", "timeZone"}` body in `tz`."""
496
+ local = dt.astimezone(ZoneInfo(tz))
497
+ return {"dateTime": local.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": tz}
498
+
499
+
500
+ def to_iso_offset(dt: datetime) -> str:
501
+ """Render `dt` as an ISO 8601 string with a numeric UTC offset."""
502
+ return dt.isoformat(timespec="seconds")
503
+
504
+
505
+ def kql_date(dt: datetime, tz: str) -> str:
506
+ """Render `dt` as a day-granular `YYYY-MM-DD` date in `tz`, for KQL filters."""
507
+ return dt.astimezone(ZoneInfo(tz)).strftime("%Y-%m-%d")
508
+
509
+
510
+ def has_time_of_day(dt: datetime | None, tz: str, *, end_of_day: bool) -> bool:
511
+ """Whether `dt` names a moment inside a day rather than that day's boundary in `tz`.
512
+
513
+ A bound that `parse_dt` produced from a date-only value lands exactly on the day's start
514
+ (or on 23:59:59 for an `end_of_day` bound), which is what a day-granular KQL date already
515
+ means — so callers can skip a client-side re-filter for those (spec §6.3).
516
+ """
517
+ if dt is None:
518
+ return False
519
+ local = dt.astimezone(ZoneInfo(tz))
520
+ boundary = (23, 59, 59) if end_of_day else (0, 0, 0)
521
+ return (local.hour, local.minute, local.second) != boundary or local.microsecond != 0
522
+
523
+
524
+ def _console(natural_width: int) -> Console:
525
+ width = max(int(os.environ.get("COLUMNS") or 200), natural_width)
526
+ return Console(
527
+ file=sys.stdout,
528
+ width=width,
529
+ force_terminal=False,
530
+ no_color="NO_COLOR" in os.environ,
531
+ soft_wrap=True,
532
+ highlight=False,
533
+ markup=False,
534
+ )
535
+
536
+
537
+ def _cell(col: Column, item: dict) -> str:
538
+ value = col.path(item) if callable(col.path) else dig(item, col.path)
539
+ if value is None:
540
+ return ""
541
+ if isinstance(value, list):
542
+ return ", ".join(str(v) for v in value)
543
+ return str(value)
544
+
545
+
546
+ def _emit_list(res: ListResult) -> None:
547
+ if not res.items:
548
+ sys.stdout.write(res.empty_text + "\n")
549
+ else:
550
+ rows = [[_cell(c, it) for c in res.columns] for it in res.items]
551
+ widths = [max(len(c.header), *(len(r[i]) for r in rows)) for i, c in enumerate(res.columns)]
552
+ table = Table(
553
+ box=None, show_edge=False, pad_edge=False, padding=(0, 2), header_style="bold"
554
+ )
555
+ for c in res.columns:
556
+ table.add_column(c.header, no_wrap=True, overflow="ignore")
557
+ for r in rows:
558
+ table.add_row(*r)
559
+ # padding=(0, 2) with pad_edge=False puts a 2-char pad on each side of every internal
560
+ # column boundary (4 chars per gap) and none at the table's outer edges.
561
+ natural_width = sum(widths) + 4 * (len(widths) - 1)
562
+ _console(natural_width).print(table)
563
+ if res.truncated:
564
+ if res.hit_cap:
565
+ note(f"(hit the {res.hit_cap}-item cap — narrow the query)")
566
+ elif res.supports_all:
567
+ note("(more results available — rerun with --all)")
568
+ else:
569
+ note("(more results available — raise --limit)")
570
+
571
+
572
+ def _to_json(result: Result) -> Any:
573
+ match result:
574
+ case ListResult():
575
+ envelope: dict[str, Any] = {
576
+ "items": result.items,
577
+ "count": len(result.items),
578
+ # What Graph returned before any client-side pass; equal to `count` when none
579
+ # ran, so a consumer never has to branch on the key being there.
580
+ "fetched": len(result.items) if result.fetched is None else result.fetched,
581
+ "cap": result.cap,
582
+ "truncated": result.truncated,
583
+ }
584
+ if result.window is not None:
585
+ envelope["window"] = result.window.to_json()
586
+ if result.query is not None:
587
+ envelope["query"] = result.query
588
+ return {**envelope, **(result.extra or {})}
589
+ case ObjectResult():
590
+ return result.obj
591
+ case TextResult():
592
+ return result.json_obj
593
+ case WriteResult():
594
+ return result.obj if result.obj is not None else {"status": "ok"}
595
+ case DryRunResult():
596
+ from mgraphctl.http import plan_to_json
597
+
598
+ return {"dryRun": True, "requests": plan_to_json(result.plan)}
599
+ case FileResult():
600
+ return {"path": str(result.path), "bytes": result.bytes, **result.meta}
601
+ case _: # pragma: no cover - exhaustive match
602
+ raise TypeError(f"unsupported result type: {type(result)!r}")
603
+
604
+
605
+ def emit(result: Result, *, json_mode: bool) -> None:
606
+ """Render `result` to stdout: one JSON document, or the appropriate text form."""
607
+ if json_mode:
608
+ sys.stdout.write(json.dumps(_to_json(result), indent=2, ensure_ascii=False) + "\n")
609
+ return
610
+ match result:
611
+ case ListResult():
612
+ _emit_list(result)
613
+ case ObjectResult():
614
+ width = max(len(label) for label, _ in result.fields)
615
+ for label, path in result.fields:
616
+ value = path(result.obj) if callable(path) else dig(result.obj, path)
617
+ sys.stdout.write(f"{label:<{width}} : {'' if value is None else value}\n")
618
+ if result.body is not None:
619
+ sys.stdout.write("\n" + result.body.rstrip("\n") + "\n")
620
+ case TextResult():
621
+ sys.stdout.write(result.text.rstrip("\n") + "\n")
622
+ case WriteResult() | FileResult():
623
+ sys.stdout.write(result.message + "\n")
624
+ case DryRunResult():
625
+ from mgraphctl.http import plan_to_text
626
+
627
+ sys.stdout.write(plan_to_text(result.plan))