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.
- runspec_windows/__init__.py +14 -0
- runspec_windows/_platform.py +65 -0
- runspec_windows/eventlog.py +87 -0
- runspec_windows/graph/__init__.py +24 -0
- runspec_windows/graph/_emit.py +20 -0
- runspec_windows/graph/account.py +48 -0
- runspec_windows/graph/auth.py +127 -0
- runspec_windows/graph/calendar.py +77 -0
- runspec_windows/graph/client.py +43 -0
- runspec_windows/graph/files.py +51 -0
- runspec_windows/graph/outlook.py +79 -0
- runspec_windows/graph/teams.py +62 -0
- runspec_windows/network.py +160 -0
- runspec_windows/processes.py +95 -0
- runspec_windows/runspec.toml +425 -0
- runspec_windows/services.py +97 -0
- runspec_windows/sessions.py +83 -0
- runspec_windows/software.py +76 -0
- runspec_windows/system_info.py +138 -0
- runspec_windows/tasks.py +59 -0
- runspec_windows-0.1.0.dist-info/METADATA +15 -0
- runspec_windows-0.1.0.dist-info/RECORD +24 -0
- runspec_windows-0.1.0.dist-info/WHEEL +4 -0
- runspec_windows-0.1.0.dist-info/entry_points.txt +36 -0
|
@@ -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})))
|