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/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))
|