runspec-windows 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.
@@ -0,0 +1,14 @@
1
+ """runspec-windows — Windows system admin + Microsoft 365 runnables for runspec.
2
+
3
+ Public API (parallels runspec-linux's ``nc_send``): ``graph_get`` and
4
+ ``get_token`` let other wrapper runnables reuse the authenticated Microsoft
5
+ Graph client without re-implementing the device-code flow.
6
+
7
+ Importing this package never requires the optional ``[graph]`` dependencies —
8
+ ``msal``/``httpx`` are imported lazily and only when a Graph call is made.
9
+ """
10
+
11
+ from runspec_windows.graph.auth import get_token
12
+ from runspec_windows.graph.client import graph_get
13
+
14
+ __all__ = ["graph_get", "get_token"]
@@ -0,0 +1,65 @@
1
+ """_platform.py — Windows execution helpers shared by the system runnables.
2
+
3
+ The OS-specific work (shelling out to ``powershell``/``sc``/``netstat`` etc.)
4
+ lives behind these thin helpers so the pure parsers in each module stay
5
+ import-clean and unit-testable on any platform. Every Windows-only runnable
6
+ calls :func:`ensure_windows` first, so running one on a non-Windows host returns
7
+ a structured JSON error instead of a traceback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ import sys
15
+
16
+ IS_WINDOWS = sys.platform == "win32"
17
+
18
+
19
+ def ensure_windows() -> None:
20
+ """Emit a JSON error and exit if not running on Windows.
21
+
22
+ Keeps non-Windows behaviour predictable (clean error, exit 1) rather than
23
+ crashing on a missing ``ipconfig``/``sc``/Win32 API.
24
+ """
25
+ if not IS_WINDOWS:
26
+ print(json.dumps({"error": "This runnable requires Windows", "platform": sys.platform}))
27
+ sys.exit(1)
28
+
29
+
30
+ def fail(message: str, **extra: object) -> None:
31
+ """Print ``{"error": message, **extra}`` as JSON and exit non-zero."""
32
+ print(json.dumps({"error": message, **extra}))
33
+ sys.exit(1)
34
+
35
+
36
+ def run_powershell(script: str, timeout: int = 30) -> str:
37
+ """Run a PowerShell snippet non-interactively and return its stdout.
38
+
39
+ Raises ``RuntimeError`` with stderr on a non-zero exit so callers can turn
40
+ it into a JSON error.
41
+ """
42
+ result = subprocess.run(
43
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=timeout,
47
+ encoding="utf-8",
48
+ errors="replace",
49
+ )
50
+ if result.returncode != 0:
51
+ raise RuntimeError(result.stderr.strip() or f"powershell exited {result.returncode}")
52
+ return result.stdout
53
+
54
+
55
+ def run_cmd(args: list[str], timeout: int = 30, check: bool = False) -> subprocess.CompletedProcess[str]:
56
+ """Run a console command and return the completed process (text mode)."""
57
+ return subprocess.run(
58
+ args,
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=timeout,
62
+ check=check,
63
+ encoding="utf-8",
64
+ errors="replace",
65
+ )
@@ -0,0 +1,87 @@
1
+ """Event log runnables: query-eventlog, recent-errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows import _platform
10
+
11
+ # Get-WinEvent numeric levels.
12
+ _LEVELS = {"critical": 1, "error": 2, "warning": 3, "information": 4}
13
+
14
+
15
+ def normalize_events(data: object) -> list[dict]:
16
+ """Reshape parsed ``Get-WinEvent | ConvertTo-Json`` output into rows."""
17
+ if isinstance(data, dict):
18
+ items = [data]
19
+ elif isinstance(data, list):
20
+ items = [d for d in data if isinstance(d, dict)]
21
+ else:
22
+ items = []
23
+ rows = []
24
+ for item in items:
25
+ message = item.get("message") or ""
26
+ if isinstance(message, str) and len(message) > 500:
27
+ message = message[:500] + "…"
28
+ rows.append(
29
+ {
30
+ "time": item.get("time"),
31
+ "level": item.get("level"),
32
+ "id": item.get("Id"),
33
+ "provider": item.get("ProviderName"),
34
+ "message": message,
35
+ }
36
+ )
37
+ return rows
38
+
39
+
40
+ def _select() -> str:
41
+ return "Select-Object @{N='time';E={$_.TimeCreated.ToString('s')}},@{N='level';E={$_.LevelDisplayName}},Id,ProviderName,@{N='message';E={$_.Message}}"
42
+
43
+
44
+ def _query(log: str, count: int, level: str | None) -> list[dict]:
45
+ filt = f"LogName='{log}'"
46
+ if level and level != "all":
47
+ filt += f"; Level={_LEVELS[level]}"
48
+ script = f"Get-WinEvent -FilterHashtable @{{{filt}}} -MaxEvents {count} -ErrorAction Stop | {_select()} | ConvertTo-Json -Compress"
49
+ out = _platform.run_powershell(script).strip()
50
+ return normalize_events(json.loads(out)) if out else []
51
+
52
+
53
+ def main_query_eventlog() -> None:
54
+ spec = rs.parse("query-eventlog")
55
+ _platform.ensure_windows()
56
+ log = str(spec.log)
57
+ level = str(spec.level)
58
+ count = int(spec.count)
59
+ try:
60
+ print(json.dumps({"log": log, "level": level, "events": _query(log, count, level)}))
61
+ except RuntimeError as e:
62
+ # Get-WinEvent raises "No events were found" when the filter matches nothing.
63
+ if "No events were found" in str(e):
64
+ print(json.dumps({"log": log, "level": level, "events": []}))
65
+ else:
66
+ _platform.fail(str(e), log=log)
67
+ except Exception as e:
68
+ _platform.fail(str(e), log=log)
69
+
70
+
71
+ def main_recent_errors() -> None:
72
+ spec = rs.parse("recent-errors")
73
+ _platform.ensure_windows()
74
+ count = int(spec.count)
75
+ result: dict[str, list[dict]] = {}
76
+ try:
77
+ for log in ("System", "Application"):
78
+ try:
79
+ result[log] = _query(log, count, "error")
80
+ except RuntimeError as e:
81
+ if "No events were found" in str(e):
82
+ result[log] = []
83
+ else:
84
+ raise
85
+ print(json.dumps(result))
86
+ except Exception as e:
87
+ _platform.fail(str(e))
@@ -0,0 +1,24 @@
1
+ """Microsoft Graph integration for runspec-windows (Outlook + Teams).
2
+
3
+ Pure HTTP via ``httpx`` + delegated auth via ``msal`` device-code flow — no
4
+ pywin32 and no client secret. The optional dependencies live behind the
5
+ ``[graph]`` extra; importing this package never requires them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class GraphError(Exception):
12
+ """Base error for Graph runnables (rendered as a JSON ``error`` payload)."""
13
+
14
+
15
+ class GraphDepError(GraphError):
16
+ """The optional ``[graph]`` dependencies (msal/httpx) are not installed."""
17
+
18
+
19
+ class GraphConfigError(GraphError):
20
+ """No Azure client id configured (RUNSPEC_GRAPH_CLIENT_ID / graph.toml)."""
21
+
22
+
23
+ class GraphAuthError(GraphError):
24
+ """No cached credentials — the user must run ``graph-login`` first."""
@@ -0,0 +1,20 @@
1
+ """_emit.py — shared JSON output + error handling for Graph runnables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from runspec_windows import _platform
10
+ from runspec_windows.graph import GraphError
11
+
12
+
13
+ def run_graph(produce: Callable[[], Any]) -> None:
14
+ """Print ``produce()`` as JSON, turning Graph errors into a JSON error payload."""
15
+ try:
16
+ print(json.dumps(produce()))
17
+ except GraphError as e:
18
+ _platform.fail(str(e), kind=type(e).__name__)
19
+ except Exception as e: # network/JSON/etc.
20
+ _platform.fail(str(e))
@@ -0,0 +1,48 @@
1
+ """Account runnables (Microsoft Graph): graph-login, graph-whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows import _platform
10
+ from runspec_windows.graph import GraphError
11
+ from runspec_windows.graph._emit import run_graph
12
+ from runspec_windows.graph.auth import device_login
13
+ from runspec_windows.graph.client import graph_get
14
+
15
+
16
+ def main_graph_login() -> None:
17
+ rs.parse("graph-login")
18
+ try:
19
+ result = device_login()
20
+ claims = result.get("id_token_claims", {})
21
+ print(
22
+ json.dumps(
23
+ {
24
+ "signed_in": True,
25
+ "user": claims.get("preferred_username") or claims.get("name"),
26
+ "scopes": result.get("scope"),
27
+ }
28
+ )
29
+ )
30
+ except GraphError as e:
31
+ _platform.fail(str(e), kind=type(e).__name__)
32
+ except Exception as e:
33
+ _platform.fail(str(e))
34
+
35
+
36
+ def main_graph_whoami() -> None:
37
+ rs.parse("graph-whoami")
38
+ run_graph(lambda: _whoami(graph_get("/me", params={"$select": "displayName,userPrincipalName,mail,jobTitle,id"})))
39
+
40
+
41
+ def _whoami(me: dict) -> dict:
42
+ return {
43
+ "display_name": me.get("displayName"),
44
+ "user_principal_name": me.get("userPrincipalName"),
45
+ "mail": me.get("mail"),
46
+ "job_title": me.get("jobTitle"),
47
+ "id": me.get("id"),
48
+ }
@@ -0,0 +1,127 @@
1
+ """auth.py — Microsoft Graph delegated auth via MSAL device-code flow.
2
+
3
+ Token cache persists to ``%APPDATA%/runspec-windows/msal_cache.bin``. The Azure
4
+ ``client_id`` (a public client — no secret) and ``tenant`` come from the
5
+ environment or ``%APPDATA%/runspec-windows/graph.toml``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from runspec_windows.graph import GraphAuthError, GraphConfigError, GraphDepError
17
+
18
+ try:
19
+ import msal
20
+ except ImportError: # optional [graph] dependency
21
+ msal = None # type: ignore[assignment]
22
+
23
+ if sys.version_info >= (3, 11):
24
+ import tomllib
25
+ else: # pragma: no cover
26
+ try:
27
+ import tomli as tomllib # type: ignore[no-redef]
28
+ except ImportError: # tomllib is the only consumer; degrade gracefully
29
+ tomllib = None # type: ignore[assignment]
30
+
31
+ DEFAULT_SCOPES = ["User.Read", "Mail.Read", "Chat.Read", "Calendars.Read", "Files.Read.All"]
32
+ _AUTHORITY_BASE = "https://login.microsoftonline.com"
33
+
34
+
35
+ def _require_msal() -> Any:
36
+ if msal is None:
37
+ raise GraphDepError("The 'msal' package is required for Microsoft Graph runnables. Install with: pip install 'runspec-windows[graph]'")
38
+ return msal
39
+
40
+
41
+ def config_dir() -> Path:
42
+ base = os.environ.get("APPDATA") or str(Path.home() / ".config")
43
+ d = Path(base) / "runspec-windows"
44
+ d.mkdir(parents=True, exist_ok=True)
45
+ return d
46
+
47
+
48
+ def _cache_path() -> Path:
49
+ return config_dir() / "msal_cache.bin"
50
+
51
+
52
+ def _read_graph_toml() -> dict:
53
+ path = config_dir() / "graph.toml"
54
+ if not path.exists() or tomllib is None:
55
+ return {}
56
+ try:
57
+ with open(path, "rb") as f:
58
+ return dict(tomllib.load(f))
59
+ except Exception:
60
+ return {}
61
+
62
+
63
+ def client_id() -> str:
64
+ cid = os.environ.get("RUNSPEC_GRAPH_CLIENT_ID") or _read_graph_toml().get("client_id")
65
+ if not cid:
66
+ raise GraphConfigError("No Azure client id. Set RUNSPEC_GRAPH_CLIENT_ID or add client_id to %APPDATA%/runspec-windows/graph.toml. See the README for app-registration steps.")
67
+ return str(cid)
68
+
69
+
70
+ def tenant() -> str:
71
+ return str(os.environ.get("RUNSPEC_GRAPH_TENANT") or _read_graph_toml().get("tenant") or "organizations")
72
+
73
+
74
+ def _load_cache() -> Any:
75
+ m = _require_msal()
76
+ cache = m.SerializableTokenCache()
77
+ path = _cache_path()
78
+ if path.exists():
79
+ cache.deserialize(path.read_text(encoding="utf-8"))
80
+ return cache
81
+
82
+
83
+ def _save_cache(cache: Any) -> None:
84
+ if cache.has_state_changed:
85
+ _cache_path().write_text(cache.serialize(), encoding="utf-8")
86
+
87
+
88
+ def _build_app(cache: Any) -> Any:
89
+ m = _require_msal()
90
+ return m.PublicClientApplication(client_id(), authority=f"{_AUTHORITY_BASE}/{tenant()}", token_cache=cache)
91
+
92
+
93
+ def get_token(scopes: list[str] | None = None) -> str:
94
+ """Return a cached access token (silent refresh), or raise ``GraphAuthError``.
95
+
96
+ Does not start an interactive flow — call ``device_login`` (the ``graph-login``
97
+ runnable) once to populate the cache.
98
+ """
99
+ scopes = scopes or DEFAULT_SCOPES
100
+ cache = _load_cache()
101
+ app = _build_app(cache)
102
+ accounts = app.get_accounts()
103
+ result = app.acquire_token_silent(scopes, account=accounts[0]) if accounts else None
104
+ _save_cache(cache)
105
+ if not result or "access_token" not in result:
106
+ raise GraphAuthError("Not signed in to Microsoft 365 — run 'graph-login' first.")
107
+ return str(result["access_token"])
108
+
109
+
110
+ def device_login(scopes: list[str] | None = None, on_prompt: Callable[[str], None] | None = None) -> dict:
111
+ """Run the device-code flow interactively and cache the resulting token.
112
+
113
+ ``on_prompt`` receives the human-readable instructions (verification URL +
114
+ code); defaults to printing them to stderr so stdout stays clean JSON.
115
+ """
116
+ scopes = scopes or DEFAULT_SCOPES
117
+ cache = _load_cache()
118
+ app = _build_app(cache)
119
+ flow = app.initiate_device_flow(scopes=scopes)
120
+ if "user_code" not in flow:
121
+ raise GraphAuthError(f"Failed to start device-code flow: {flow.get('error_description', flow)}")
122
+ (on_prompt or (lambda m: print(m, file=sys.stderr)))(flow["message"])
123
+ result = app.acquire_token_by_device_flow(flow)
124
+ _save_cache(cache)
125
+ if "access_token" not in result:
126
+ raise GraphAuthError(result.get("error_description", "Device-code login failed"))
127
+ return result
@@ -0,0 +1,77 @@
1
+ """Calendar runnables (Microsoft Graph): calendar-upcoming, calendar-today, next-meeting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows.graph._emit import run_graph
10
+ from runspec_windows.graph.client import graph_get
11
+
12
+ _EVENT_SELECT = "subject,start,end,organizer,location,onlineMeeting,isOnlineMeeting,webLink"
13
+
14
+
15
+ def format_events(data: dict) -> list[dict]:
16
+ """Reshape a Graph calendarView response (``{"value": [...]}``) into rows."""
17
+ rows = []
18
+ for ev in data.get("value", []):
19
+ organizer = ((ev.get("organizer") or {}).get("emailAddress") or {}).get("name")
20
+ online = ev.get("onlineMeeting") or {}
21
+ rows.append(
22
+ {
23
+ "subject": ev.get("subject"),
24
+ "start": (ev.get("start") or {}).get("dateTime"),
25
+ "end": (ev.get("end") or {}).get("dateTime"),
26
+ "organizer": organizer,
27
+ "location": (ev.get("location") or {}).get("displayName"),
28
+ "is_online": ev.get("isOnlineMeeting"),
29
+ "join_url": online.get("joinUrl"),
30
+ "web_link": ev.get("webLink"),
31
+ }
32
+ )
33
+ return rows
34
+
35
+
36
+ def _iso(dt: datetime) -> str:
37
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
38
+
39
+
40
+ def _calendar_view(start: datetime, end: datetime, count: int) -> list[dict]:
41
+ return format_events(
42
+ graph_get(
43
+ "/me/calendarView",
44
+ params={
45
+ "startDateTime": _iso(start),
46
+ "endDateTime": _iso(end),
47
+ "$select": _EVENT_SELECT,
48
+ "$orderby": "start/dateTime",
49
+ "$top": count,
50
+ },
51
+ )
52
+ )
53
+
54
+
55
+ def main_calendar_upcoming() -> None:
56
+ spec = rs.parse("calendar-upcoming")
57
+ days = int(spec.days)
58
+ count = int(spec.count)
59
+ now = datetime.now(timezone.utc)
60
+ run_graph(lambda: _calendar_view(now, now + timedelta(days=days), count))
61
+
62
+
63
+ def main_calendar_today() -> None:
64
+ rs.parse("calendar-today")
65
+ start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
66
+ run_graph(lambda: _calendar_view(start, start + timedelta(days=1), 50))
67
+
68
+
69
+ def main_next_meeting() -> None:
70
+ rs.parse("next-meeting")
71
+ now = datetime.now(timezone.utc)
72
+
73
+ def produce() -> dict:
74
+ events = _calendar_view(now, now + timedelta(days=30), 1)
75
+ return events[0] if events else {"message": "No upcoming meetings in the next 30 days"}
76
+
77
+ run_graph(produce)
@@ -0,0 +1,43 @@
1
+ """client.py — thin authenticated Microsoft Graph GET helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runspec_windows.graph import GraphDepError, GraphError
8
+ from runspec_windows.graph.auth import get_token
9
+
10
+ try:
11
+ import httpx
12
+ except ImportError: # optional [graph] dependency
13
+ httpx = None # type: ignore[assignment]
14
+
15
+ GRAPH_BASE = "https://graph.microsoft.com/v1.0"
16
+
17
+
18
+ def graph_get(path: str, params: dict | None = None, scopes: list[str] | None = None, timeout: int = 30) -> dict:
19
+ """GET ``GRAPH_BASE + path`` with a bearer token and return parsed JSON.
20
+
21
+ Raises ``GraphError`` on a non-2xx response, surfacing the Graph error
22
+ message. ``path`` should start with ``/`` (e.g. ``/me/messages``).
23
+ """
24
+ if httpx is None:
25
+ raise GraphDepError("The 'httpx' package is required for Microsoft Graph runnables. Install with: pip install 'runspec-windows[graph]'")
26
+ token = get_token(scopes)
27
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
28
+ if params and "$search" in params:
29
+ # Graph requires ConsistencyLevel: eventual for $search on messages.
30
+ headers["ConsistencyLevel"] = "eventual"
31
+ resp = httpx.get(GRAPH_BASE + path, headers=headers, params=params, timeout=timeout)
32
+ if resp.status_code >= 400:
33
+ raise GraphError(_error_message(resp))
34
+ return resp.json() # type: ignore[no-any-return]
35
+
36
+
37
+ def _error_message(resp: Any) -> str:
38
+ try:
39
+ body = resp.json()
40
+ msg = body.get("error", {}).get("message") or str(body)
41
+ except Exception:
42
+ msg = resp.text[:500]
43
+ return f"Graph {resp.status_code}: {msg}"
@@ -0,0 +1,51 @@
1
+ """OneDrive/SharePoint file runnables (Microsoft Graph): onedrive-recent, onedrive-search, onedrive-list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows.graph._emit import run_graph
10
+ from runspec_windows.graph.client import graph_get
11
+
12
+ _ITEM_SELECT = "name,size,lastModifiedDateTime,webUrl,id,folder,file,parentReference"
13
+
14
+
15
+ def format_items(data: dict) -> list[dict]:
16
+ """Reshape a Graph driveItem collection (``{"value": [...]}``) into rows."""
17
+ rows = []
18
+ for it in data.get("value", []):
19
+ rows.append(
20
+ {
21
+ "name": it.get("name"),
22
+ "is_folder": "folder" in it,
23
+ "size": it.get("size"),
24
+ "last_modified": it.get("lastModifiedDateTime"),
25
+ "web_url": it.get("webUrl"),
26
+ "item_id": it.get("id"),
27
+ "path": (it.get("parentReference") or {}).get("path"),
28
+ }
29
+ )
30
+ return rows
31
+
32
+
33
+ def main_onedrive_recent() -> None:
34
+ spec = rs.parse("onedrive-recent")
35
+ count = int(spec.count)
36
+ run_graph(lambda: format_items(graph_get("/me/drive/recent", params={"$top": count})))
37
+
38
+
39
+ def main_onedrive_search() -> None:
40
+ spec = rs.parse("onedrive-search")
41
+ query = str(spec.query)
42
+ count = int(spec.count)
43
+ path = f"/me/drive/root/search(q='{quote(query)}')"
44
+ run_graph(lambda: format_items(graph_get(path, params={"$top": count, "$select": _ITEM_SELECT})))
45
+
46
+
47
+ def main_onedrive_list() -> None:
48
+ spec = rs.parse("onedrive-list")
49
+ folder = str(spec.path).strip("/")
50
+ path = f"/me/drive/root:/{quote(folder)}:/children" if folder else "/me/drive/root/children"
51
+ run_graph(lambda: format_items(graph_get(path, params={"$select": _ITEM_SELECT})))
@@ -0,0 +1,79 @@
1
+ """Outlook runnables (Microsoft Graph): outlook-unread, outlook-search, outlook-folders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import runspec as rs
6
+
7
+ from runspec_windows.graph._emit import run_graph
8
+ from runspec_windows.graph.client import graph_get
9
+
10
+ _MESSAGE_SELECT = "subject,from,receivedDateTime,isRead,bodyPreview,webLink"
11
+
12
+
13
+ def format_messages(data: dict) -> list[dict]:
14
+ """Reshape a Graph messages response (``{"value": [...]}``) into rows."""
15
+ rows = []
16
+ for msg in data.get("value", []):
17
+ sender = (msg.get("from") or {}).get("emailAddress") or {}
18
+ rows.append(
19
+ {
20
+ "subject": msg.get("subject"),
21
+ "from": sender.get("name"),
22
+ "from_address": sender.get("address"),
23
+ "received": msg.get("receivedDateTime"),
24
+ "is_read": msg.get("isRead"),
25
+ "preview": msg.get("bodyPreview"),
26
+ "web_link": msg.get("webLink"),
27
+ }
28
+ )
29
+ return rows
30
+
31
+
32
+ def format_folders(data: dict) -> list[dict]:
33
+ """Reshape a Graph mailFolders response into rows with unread/total counts."""
34
+ return [
35
+ {
36
+ "name": f.get("displayName"),
37
+ "unread": f.get("unreadItemCount"),
38
+ "total": f.get("totalItemCount"),
39
+ "id": f.get("id"),
40
+ }
41
+ for f in data.get("value", [])
42
+ ]
43
+
44
+
45
+ def main_outlook_unread() -> None:
46
+ spec = rs.parse("outlook-unread")
47
+ count = int(spec.count)
48
+ run_graph(
49
+ lambda: format_messages(
50
+ graph_get(
51
+ "/me/mailFolders/inbox/messages",
52
+ params={
53
+ "$filter": "isRead eq false",
54
+ "$top": count,
55
+ "$select": _MESSAGE_SELECT,
56
+ "$orderby": "receivedDateTime desc",
57
+ },
58
+ )
59
+ )
60
+ )
61
+
62
+
63
+ def main_outlook_search() -> None:
64
+ spec = rs.parse("outlook-search")
65
+ query = str(spec.query)
66
+ count = int(spec.count)
67
+ run_graph(
68
+ lambda: format_messages(
69
+ graph_get(
70
+ "/me/messages",
71
+ params={"$search": f'"{query}"', "$top": count, "$select": _MESSAGE_SELECT},
72
+ )
73
+ )
74
+ )
75
+
76
+
77
+ def main_outlook_folders() -> None:
78
+ rs.parse("outlook-folders")
79
+ run_graph(lambda: format_folders(graph_get("/me/mailFolders", params={"$top": 100})))