2776813df3
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.
204 lines
10 KiB
Python
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 ----
|