Files
hermes-agent/tools/microsoft_graph_client.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

204 lines
10 KiB
Python

"""Reusable Microsoft Graph REST client helpers."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Any, Awaitable, Callable
import httpx
from agent.retry_utils import parse_retry_after_seconds
from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider, format_graph_error
DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
Headers = dict[str, str] | None
Params = dict[str, Any] | None
class MicrosoftGraphClientError(RuntimeError):
"""Base class for Graph client failures."""
class MicrosoftGraphAPIError(MicrosoftGraphClientError):
"""Raised when a Graph API request fails."""
def __init__(self, status_code: int, method: str, url: str, message: str, *,
retry_after_seconds: float | None = None, payload: Any = None) -> None:
self.status_code, self.method, self.url = status_code, method, url
self.retry_after_seconds, self.payload = retry_after_seconds, payload
super().__init__(f"Microsoft Graph API error {status_code} for {method} {url}: {message}")
class MicrosoftGraphClient:
"""Minimal async Graph client. Retry policy (JSON requests and streaming downloads
alike): transport errors back off exponentially; 401 clears the token cache and
refetches; 429/5xx honor ``Retry-After``. Each attempt uses a fresh ``AsyncClient``."""
def __init__(self, token_provider: MicrosoftGraphTokenProvider, *,
base_url: str = DEFAULT_GRAPH_BASE_URL, timeout: float = 60.0, max_retries: int = 3,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Callable[[float], Awaitable[None]] | None = None,
user_agent: str = "Hermes-Agent/graph-client") -> None:
self.token_provider, self.base_url, self.timeout = token_provider, base_url.rstrip("/"), timeout
self.max_retries, self.user_agent = max(0, int(max_retries)), user_agent
self._transport, self._sleep = transport, sleep or asyncio.sleep
async def get_json(self, path: str, *, params: Params = None, headers: Headers = None) -> Any:
return self._decode_json(await self._request("GET", path, params=params, headers=headers))
async def post_json(self, path: str, *, json_body: Any | None = None, headers: Headers = None) -> Any:
return self._decode_json(await self._request("POST", path, json_body=json_body, headers=headers))
async def patch_json(self, path: str, *, json_body: Any | None = None, headers: Headers = None) -> Any:
"""Decoded body, or ``{}`` for a 204 / bodiless response."""
response = await self._request("PATCH", path, json_body=json_body, headers=headers)
return self._decode_json(response) if response.status_code != 204 and response.content else {}
async def delete(self, path: str, *, headers: Headers = None) -> dict[str, Any]:
"""Decoded body, or ``{"deleted": True, "status_code"}`` for a 204 / bodiless response."""
response = await self._request("DELETE", path, headers=headers)
if response.status_code != 204 and response.content:
return self._decode_json(response)
return {"deleted": True, "status_code": response.status_code}
async def collect_paginated(self, path: str, *, params: Params = None, headers: Headers = None) -> list[Any]:
"""Follow ``@odata.nextLink`` and concatenate every page's ``value`` list."""
items: list[Any] = []
# Query params go on the first request only; @odata.nextLink already embeds them.
next_url, next_params = self._resolve_url(path), dict(params or {})
while next_url:
payload = self._decode_json(await self._request("GET", next_url, params=next_params or None, headers=headers))
if not isinstance(payload, dict):
raise MicrosoftGraphClientError(
f"Expected paginated Graph response dict, got {type(payload).__name__}.")
if isinstance(payload.get("value"), list):
items.extend(payload["value"])
next_url, next_params = payload.get("@odata.nextLink"), {}
return items
async def download_to_file(self, path: str, destination: str | Path, *, headers: Headers = None,
chunk_size: int = 65536) -> dict[str, Any]:
"""Stream a Graph resource to disk chunk-by-chunk (large recordings never
fit in memory); written to ``.part`` and renamed into place only on success."""
url, target = self._resolve_url(path), Path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
tmp_target = target.with_suffix(target.suffix + ".part")
async def perform(client: httpx.AsyncClient, request_headers: dict[str, str]):
try:
async with client.stream("GET", url, headers=request_headers) as response:
if response.status_code >= 400:
await response.aread() # small error body -> meaningful message
return response, None
with tmp_target.open("wb") as handle:
async for chunk in response.aiter_bytes(chunk_size=chunk_size):
if chunk:
handle.write(chunk)
return response, response.headers.get("content-type")
except httpx.HTTPError:
tmp_target.unlink(missing_ok=True)
raise
content_type = await self._with_retries("GET", url, "*/*", None, headers, perform, "download")
os.replace(tmp_target, target)
return {"path": str(target), "size_bytes": target.stat().st_size, "content_type": content_type}
async def _request(self, method: str, path_or_url: str, *, params: Params = None,
json_body: Any | None = None, headers: Headers = None) -> httpx.Response:
url = self._resolve_url(path_or_url)
async def perform(client: httpx.AsyncClient, request_headers: dict[str, str]):
response = await client.request(method, url, params=params, json=json_body, headers=request_headers)
return response, response
return await self._with_retries(method, url, "application/json", json_body, headers, perform, "request")
async def _with_retries(
self, method: str, url: str, accept: str, json_body: Any | None, headers: Headers,
perform: Callable[[httpx.AsyncClient, dict[str, str]], Awaitable[tuple[httpx.Response, Any]]],
kind: str) -> Any:
"""Run ``perform`` (-> ``(response, result)``) under the retry policy. ``kind``
only labels transport-failure messages. Raises ``MicrosoftGraphAPIError`` once
retries are exhausted or the status is not retryable; only 401 forces a token refresh."""
last_error: Exception | None = None
for attempt in range(self.max_retries + 1):
token = await self.token_provider.get_access_token(
force_refresh=isinstance(last_error, MicrosoftGraphAPIError) and last_error.status_code == 401)
request_headers = {"Authorization": f"Bearer {token}", "Accept": accept, "User-Agent": self.user_agent,
**({"Content-Type": "application/json"} if json_body is not None else {}),
**(headers or {})}
exhausted = attempt >= self.max_retries
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout), transport=self._transport) as client:
response, result = await perform(client, request_headers)
except httpx.HTTPError as exc:
last_error, response = exc, None
if exhausted:
raise MicrosoftGraphClientError(
f"Microsoft Graph {kind} failed for {method} {url}: {exc}") from exc
else:
if response.status_code < 400:
return result
last_error, status = self._build_api_error(method, url, response), response.status_code
if exhausted or not (status in (401, 429) or 500 <= status < 600):
raise last_error
if status == 401:
self.token_provider.clear_cache()
await self._sleep(self._retry_delay(response, attempt))
raise MicrosoftGraphClientError(f"Microsoft Graph {kind} exhausted retries for {method} {url}.")
def _resolve_url(self, path_or_url: str) -> str:
if path_or_url.startswith(("http://", "https://")):
return path_or_url
return f"{self.base_url}{path_or_url if path_or_url.startswith('/') else '/' + path_or_url}"
@staticmethod
def _decode_json(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError as exc:
raise MicrosoftGraphClientError(
"Microsoft Graph response was not valid JSON for "
f"{response.request.method} {response.request.url}") from exc
@staticmethod
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
retry_after = parse_retry_after_seconds(response.headers) if response is not None else None
return min(8.0, 0.5 * (2 ** attempt)) if retry_after is None else retry_after
@staticmethod
def _build_api_error(method: str, url: str, response: httpx.Response) -> MicrosoftGraphAPIError:
try:
payload: Any = response.json()
except ValueError:
payload = None
detail = format_graph_error(payload.get("error")) if isinstance(payload, dict) else None
return MicrosoftGraphAPIError(
response.status_code, method, url, response.text.strip() or "unknown error" if detail is None else detail,
retry_after_seconds=parse_retry_after_seconds(response.headers), payload=payload)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import AsyncIterator # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'GraphCredentials': ('tools.microsoft_graph_auth', 'GraphCredentials'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----