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/fixtures.py ADDED
@@ -0,0 +1,149 @@
1
+ """Record/replay httpx transport for offline runs and tests (spec §5.8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from mgraphctl import config
13
+ from mgraphctl.errors import FixtureError
14
+ from mgraphctl.http import redact_body
15
+
16
+ # Recorded responses never carry credentials, and never carry framing that would not
17
+ # survive re-serialising the body.
18
+ DROPPED_HEADERS = frozenset(
19
+ {"authorization", "set-cookie", "content-length", "content-encoding", "transfer-encoding"}
20
+ )
21
+
22
+
23
+ def fixture_key(request: httpx.Request) -> str:
24
+ """`"<METHOD> <path>?<query>"`; the query is dropped for unauthenticated requests.
25
+
26
+ Download targets and `uploadUrl` PUTs are pre-authenticated: their query strings carry
27
+ per-request tokens that differ on every run, so they cannot be part of the key.
28
+ """
29
+ if "authorization" in request.headers:
30
+ return f"{request.method} {request.url.raw_path.decode()}"
31
+ return f"{request.method} {request.url.path}"
32
+
33
+
34
+ def fixture_path(dir: Path, key: str) -> Path: # noqa: A002 - name fixed by the Phase 0 contract
35
+ """File holding the recorded responses for `key`."""
36
+ return Path(dir) / (hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] + ".json")
37
+
38
+
39
+ def _clean_headers(headers: httpx.Headers) -> dict[str, str]:
40
+ cleaned = {}
41
+ for name, value in headers.items():
42
+ lowered = name.lower()
43
+ if lowered in DROPPED_HEADERS:
44
+ continue
45
+ cleaned[lowered] = value.split("?", 1)[0] if lowered == "location" else value
46
+ return cleaned
47
+
48
+
49
+ def _encode_body(response: httpx.Response) -> dict:
50
+ raw = response.content
51
+ if not raw:
52
+ return {}
53
+ content_type = response.headers.get("content-type", "")
54
+ if "json" in content_type:
55
+ try:
56
+ # A recorded body must never keep a token: the OAuth pair and the `uploadUrl`
57
+ # query string are redacted here exactly as the debug log redacts them.
58
+ return {"body": json.loads(redact_body(raw.decode("utf-8")))}
59
+ except (json.JSONDecodeError, UnicodeDecodeError):
60
+ pass
61
+ if content_type.startswith("text/"):
62
+ return {"body_text": raw.decode("utf-8", errors="replace")}
63
+ return {"body_b64": base64.b64encode(raw).decode("ascii")}
64
+
65
+
66
+ def _decode_body(entry: dict) -> bytes:
67
+ if "body" in entry:
68
+ return json.dumps(entry["body"], ensure_ascii=False).encode("utf-8")
69
+ if "body_text" in entry:
70
+ return entry["body_text"].encode("utf-8")
71
+ if "body_b64" in entry:
72
+ return base64.b64decode(entry["body_b64"])
73
+ return b""
74
+
75
+
76
+ class FixtureTransport(httpx.BaseTransport):
77
+ """Replays recorded Graph traffic, or records it when `record` is set."""
78
+
79
+ def __init__(
80
+ self,
81
+ dir: Path, # noqa: A002 - name fixed by the Phase 0 contract
82
+ *,
83
+ record: bool,
84
+ inner: httpx.BaseTransport | None = None,
85
+ ) -> None:
86
+ self.dir = Path(dir)
87
+ self.record = record
88
+ self.inner = inner if inner is not None else (httpx.HTTPTransport() if record else None)
89
+ self._cursor: dict[str, int] = {}
90
+
91
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
92
+ if request.url.host == config.TOKEN_HOST:
93
+ raise FixtureError(
94
+ "FIXTURE_FORBIDDEN_HOST",
95
+ f"refusing to record or replay {config.TOKEN_HOST} traffic",
96
+ )
97
+ key = fixture_key(request)
98
+ path = fixture_path(self.dir, key)
99
+ return self._record(request, key, path) if self.record else self._replay(key, path)
100
+
101
+ def _record(self, request: httpx.Request, key: str, path: Path) -> httpx.Response:
102
+ if self.inner is None: # pragma: no cover - guarded by __init__
103
+ raise FixtureError("FIXTURE_MISSING", "recording needs an inner transport")
104
+ response = self.inner.handle_request(request)
105
+ response.request = request
106
+ response.read()
107
+ entry = {
108
+ "status": response.status_code,
109
+ "headers": _clean_headers(response.headers),
110
+ **_encode_body(response),
111
+ }
112
+ document = (
113
+ json.loads(path.read_text(encoding="utf-8"))
114
+ if path.exists()
115
+ else {"key": key, "responses": []}
116
+ )
117
+ document["responses"].append(entry)
118
+ self.dir.mkdir(parents=True, exist_ok=True)
119
+ path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
120
+ # Hand back the live response, headers untouched, so recording behaves like a real run.
121
+ return httpx.Response(
122
+ response.status_code, headers=response.headers, content=response.content
123
+ )
124
+
125
+ def _replay(self, key: str, path: Path) -> httpx.Response:
126
+ if not path.exists():
127
+ raise FixtureError("FIXTURE_MISSING", f"no fixture for {key} (expected {path})")
128
+ document = json.loads(path.read_text(encoding="utf-8"))
129
+ responses = document.get("responses") or []
130
+ index = self._cursor.get(key, 0)
131
+ if index >= len(responses):
132
+ raise FixtureError(
133
+ "FIXTURE_EXHAUSTED",
134
+ f"fixture for {key} has only {len(responses)} response(s) ({path})",
135
+ )
136
+ self._cursor[key] = index + 1
137
+ entry = responses[index]
138
+ return httpx.Response(
139
+ entry["status"], headers=entry.get("headers") or {}, content=_decode_body(entry)
140
+ )
141
+
142
+ def close(self) -> None:
143
+ if self.inner is not None:
144
+ self.inner.close()
145
+
146
+
147
+ def transport_from_env(s: config.Settings) -> FixtureTransport | None:
148
+ """The transport implied by `MGRAPHCTL_FIXTURE_DIR` / `MGRAPHCTL_RECORD`, or `None`."""
149
+ return FixtureTransport(s.fixture_dir, record=s.record) if s.fixture_dir else None
@@ -0,0 +1 @@
1
+ """Pure Microsoft Graph service functions (no CLI, no rendering)."""
@@ -0,0 +1,313 @@
1
+ """Graph operations for Outlook calendar (spec §8.4).
2
+
3
+ Pure: client and parameters in, Graph dicts / PageResult / Plan out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, time, timedelta
11
+ from typing import Any
12
+
13
+ from mgraphctl import odata
14
+ from mgraphctl.errors import UsageError
15
+ from mgraphctl.http import JSON_HEADERS, GraphClient, PageResult, Plan, PlannedRequest
16
+ from mgraphctl.render import fmt_person, iso_duration, to_graph_dtz, to_iso_offset
17
+ from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
18
+
19
+ LIST_SELECT = (
20
+ "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,"
21
+ "isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink"
22
+ )
23
+ PAGE_LIST, CAP_LIST = 50, 200
24
+
25
+ CALENDARS_SELECT = "id,name,isDefaultCalendar,canEdit,owner,color"
26
+ PAGE_CALENDARS, CAP_CALENDARS = 100, 500
27
+
28
+ RESPOND_ENDPOINTS = {"accept": "accept", "decline": "decline", "tentative": "tentativelyAccept"}
29
+ RESPOND_STATUS = {
30
+ "accept": "accepted",
31
+ "decline": "declined",
32
+ "tentative": "tentativelyAccepted",
33
+ }
34
+
35
+
36
+ # --------------------------------------------------------------------------- resolution
37
+
38
+
39
+ def list_calendars(client: GraphClient) -> list[dict]:
40
+ """Every calendar the signed-in user has, id and display fields only."""
41
+ return client.paginate(
42
+ "/me/calendars",
43
+ params={"$select": CALENDARS_SELECT},
44
+ limit=None,
45
+ all_=True,
46
+ cap=CAP_CALENDARS,
47
+ page_size=PAGE_CALENDARS,
48
+ ).items
49
+
50
+
51
+ def resolve_calendar(client: GraphClient, value: str | None) -> str | None:
52
+ """`--calendar` resolution (spec §6.6): an id, or a case-insensitive name in /me/calendars.
53
+
54
+ `None` (no `--calendar` given) resolves to `None`, meaning "use the default calendar".
55
+ """
56
+ if value is None:
57
+ return None
58
+ bare, forced = split_id_prefix(value)
59
+ if forced or looks_like_id(value, "calendar"):
60
+ return bare
61
+ calendars = list_calendars(client)
62
+ return pick_unique(calendars, "name", value, what="calendar")["id"]
63
+
64
+
65
+ # --------------------------------------------------------------------------- list / get
66
+
67
+
68
+ def flags(event: dict) -> str:
69
+ """`T` for an online meeting, `X` for a cancelled event (spec §8.4 `list` Notes)."""
70
+ marks = []
71
+ if event.get("isOnlineMeeting"):
72
+ marks.append("T")
73
+ if event.get("isCancelled"):
74
+ marks.append("X")
75
+ return "".join(marks)
76
+
77
+
78
+ def _search_haystack(event: dict) -> str:
79
+ parts = [event.get("subject") or ""]
80
+ organizer = (event.get("organizer") or {}).get("emailAddress") or {}
81
+ parts += [organizer.get("name") or "", organizer.get("address") or ""]
82
+ for attendee in event.get("attendees") or []:
83
+ email = attendee.get("emailAddress") or {}
84
+ parts += [email.get("name") or "", email.get("address") or ""]
85
+ return " ".join(parts).casefold()
86
+
87
+
88
+ def filter_search(items: list[dict], term: str) -> list[dict]:
89
+ """Client-side `--search`: keep events whose subject/organizer/attendees contain `term`."""
90
+ needle = term.casefold()
91
+ return [event for event in items if needle in _search_haystack(event)]
92
+
93
+
94
+ def calendar_view(
95
+ client: GraphClient,
96
+ *,
97
+ start: datetime,
98
+ end: datetime,
99
+ calendar_id: str | None,
100
+ select: str | None,
101
+ limit: int | None,
102
+ all_: bool,
103
+ tz: str,
104
+ ) -> PageResult:
105
+ """`calendar list` (spec §8.4): a `calendarView` window, default or a named calendar."""
106
+ path = (
107
+ "/me/calendarView"
108
+ if calendar_id is None
109
+ else odata.p("me", "calendars", calendar_id, "calendarView")
110
+ )
111
+ params = {
112
+ "startDateTime": to_iso_offset(start),
113
+ "endDateTime": to_iso_offset(end),
114
+ "$select": select or LIST_SELECT,
115
+ "$orderby": "start/dateTime",
116
+ }
117
+ return client.paginate(
118
+ path,
119
+ params=params,
120
+ outlook_tz=True,
121
+ limit=limit,
122
+ all_=all_,
123
+ cap=CAP_LIST,
124
+ page_size=PAGE_LIST,
125
+ )
126
+
127
+
128
+ def get_event(client: GraphClient, event_id: str, *, html: bool, tz: str) -> dict:
129
+ """`calendar get` (spec §8.4): the full event, text body unless `--html`."""
130
+ return client.get(odata.p("me", "events", event_id), outlook_tz=True, text_body=not html)
131
+
132
+
133
+ def attendees_block(event: dict) -> str:
134
+ """`Attendees` section for `calendar get` text output: one `Name <addr> (status)` per line."""
135
+ lines = ["Attendees"]
136
+ for attendee in event.get("attendees") or []:
137
+ status = (attendee.get("status") or {}).get("response") or "none"
138
+ lines.append(f" {fmt_person(attendee)} ({status})")
139
+ return "\n".join(lines)
140
+
141
+
142
+ # --------------------------------------------------------------------------- create / update
143
+
144
+
145
+ @dataclass
146
+ class EventParams:
147
+ """The options every event write verb shares (spec §8.4 `create`/`update`)."""
148
+
149
+ subject: str | None = None
150
+ start: datetime | None = None
151
+ end: datetime | None = None
152
+ duration: timedelta | None = None
153
+ all_day: bool | None = None
154
+ attendees: list[str] = field(default_factory=list)
155
+ optional: list[str] = field(default_factory=list)
156
+ body: str | None = None
157
+ html: bool = False
158
+ location: str | None = None
159
+ teams: bool | None = None
160
+ reminder: int | None = None
161
+ show_as: str | None = None
162
+ categories: list[str] = field(default_factory=list)
163
+ calendar: str | None = None
164
+ transaction_id: str | None = None
165
+
166
+
167
+ def event_body(p: EventParams, tz: str, *, partial: bool) -> dict:
168
+ """The JSON body for `create` (`partial=False`) or `update` (`partial=True`, given fields)."""
169
+ if p.all_day and p.start is not None and p.start.time() != time():
170
+ raise UsageError("USAGE", "--all-day needs a date-only --start/--end")
171
+ body: dict[str, Any] = {}
172
+ if p.subject is not None:
173
+ body["subject"] = p.subject
174
+ if p.start is not None:
175
+ body["start"] = to_graph_dtz(p.start, tz)
176
+ end_dt: datetime | None = None
177
+ if p.end is not None:
178
+ end_dt = p.end
179
+ elif p.start is not None and p.duration is not None:
180
+ end_dt = p.start + p.duration
181
+ elif p.start is not None and p.all_day:
182
+ end_dt = p.start + timedelta(days=1)
183
+ if end_dt is not None:
184
+ body["end"] = to_graph_dtz(end_dt, tz)
185
+ if not partial:
186
+ body["isAllDay"] = bool(p.all_day)
187
+ elif p.all_day is not None:
188
+ body["isAllDay"] = p.all_day
189
+ attendees = [{"emailAddress": {"address": a}, "type": "required"} for a in p.attendees]
190
+ attendees += [{"emailAddress": {"address": a}, "type": "optional"} for a in p.optional]
191
+ if attendees:
192
+ body["attendees"] = attendees
193
+ if p.body is not None:
194
+ body["body"] = {"contentType": "HTML" if p.html else "Text", "content": p.body}
195
+ if p.location is not None:
196
+ body["location"] = {"displayName": p.location}
197
+ if p.teams is not None:
198
+ body["isOnlineMeeting"] = p.teams
199
+ if p.teams:
200
+ body["onlineMeetingProvider"] = "teamsForBusiness"
201
+ if p.reminder is not None:
202
+ body["reminderMinutesBeforeStart"] = p.reminder
203
+ if p.show_as is not None:
204
+ body["showAs"] = p.show_as
205
+ if p.categories:
206
+ body["categories"] = list(p.categories)
207
+ if not partial:
208
+ body["transactionId"] = p.transaction_id or str(uuid.uuid4())
209
+ return body
210
+
211
+
212
+ def plan_create(client: GraphClient, p: EventParams, tz: str) -> Plan:
213
+ body = event_body(p, tz, partial=False)
214
+ path = "/me/events" if p.calendar is None else odata.p("me", "calendars", p.calendar, "events")
215
+ return [PlannedRequest("POST", client.url(path), dict(JSON_HEADERS), body)]
216
+
217
+
218
+ def plan_update(client: GraphClient, event_id: str, p: EventParams, tz: str) -> Plan:
219
+ body = event_body(p, tz, partial=True)
220
+ url = client.url(odata.p("me", "events", event_id))
221
+ return [PlannedRequest("PATCH", url, dict(JSON_HEADERS), body)]
222
+
223
+
224
+ def plan_delete(client: GraphClient, event_id: str) -> Plan:
225
+ url = client.url(odata.p("me", "events", event_id))
226
+ return [PlannedRequest("DELETE", url, {}, None, expect="none")]
227
+
228
+
229
+ def plan_respond(
230
+ client: GraphClient,
231
+ event_id: str,
232
+ action: str,
233
+ *,
234
+ comment: str | None,
235
+ send: bool,
236
+ propose: tuple[datetime, datetime] | None,
237
+ tz: str,
238
+ ) -> Plan:
239
+ endpoint = RESPOND_ENDPOINTS[action]
240
+ body: dict[str, Any] = {"sendResponse": send}
241
+ if comment is not None:
242
+ body["comment"] = comment
243
+ if propose is not None:
244
+ start, end = propose
245
+ body["proposedNewTime"] = {"start": to_graph_dtz(start, tz), "end": to_graph_dtz(end, tz)}
246
+ url = client.url(odata.p("me", "events", event_id, endpoint))
247
+ return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
248
+
249
+
250
+ def run_plan(client: GraphClient, plan: Plan) -> list:
251
+ """Execute a plan whose steps are independent (no ids threaded between them)."""
252
+ return [client.execute(step) for step in plan]
253
+
254
+
255
+ # --------------------------------------------------------------------------- availability
256
+
257
+
258
+ def get_schedule(
259
+ client: GraphClient, *, users: list[str], start: datetime, end: datetime, interval: int, tz: str
260
+ ) -> dict:
261
+ """`calendar availability` (spec §8.4): `getSchedule` for one or more mailboxes."""
262
+ body = {
263
+ "schedules": list(users),
264
+ "startTime": to_graph_dtz(start, tz),
265
+ "endTime": to_graph_dtz(end, tz),
266
+ "availabilityViewInterval": interval,
267
+ }
268
+ return client.post("/me/calendar/getSchedule", json=body)
269
+
270
+
271
+ def free_windows(
272
+ schedule_item: dict, start: datetime, interval: int
273
+ ) -> list[tuple[datetime, datetime]]:
274
+ """Free windows from `availabilityView` (`0` = free), merging consecutive free slots."""
275
+ view = schedule_item.get("availabilityView") or ""
276
+ windows: list[tuple[datetime, datetime]] = []
277
+ slot_start: datetime | None = None
278
+ for i, ch in enumerate(view):
279
+ slot_time = start + timedelta(minutes=interval * i)
280
+ if ch == "0":
281
+ if slot_start is None:
282
+ slot_start = slot_time
283
+ elif slot_start is not None:
284
+ windows.append((slot_start, slot_time))
285
+ slot_start = None
286
+ if slot_start is not None:
287
+ windows.append((slot_start, start + timedelta(minutes=interval * len(view))))
288
+ return windows
289
+
290
+
291
+ def find_times(
292
+ client: GraphClient,
293
+ *,
294
+ attendees: list[str],
295
+ duration: timedelta,
296
+ start: datetime,
297
+ end: datetime,
298
+ max_candidates: int,
299
+ domain: str,
300
+ tz: str,
301
+ ) -> dict:
302
+ """`calendar find-times` (spec §8.4): `findMeetingTimes`."""
303
+ body = {
304
+ "attendees": [{"emailAddress": {"address": a}, "type": "required"} for a in attendees],
305
+ "timeConstraint": {
306
+ "activityDomain": domain,
307
+ "timeSlots": [{"start": to_graph_dtz(start, tz), "end": to_graph_dtz(end, tz)}],
308
+ },
309
+ "meetingDuration": iso_duration(duration),
310
+ "maxCandidates": max_candidates,
311
+ "returnSuggestionReasons": True,
312
+ }
313
+ return client.post("/me/findMeetingTimes", json=body)