diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index e4c1b5dbeea..1d58e801f46 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -46,27 +46,75 @@ import re from pathlib import Path as _Path from typing import Any, Callable, Dict, List, Optional, Tuple -try: - import httplib2 - from google.cloud import pubsub_v1 - from google.api_core import exceptions as gax_exceptions - from google.oauth2 import service_account - from google_auth_httplib2 import AuthorizedHttp - from googleapiclient.discovery import build as build_service - from googleapiclient.errors import HttpError - from googleapiclient.http import MediaFileUpload +# Heavy google-cloud + googleapiclient imports are deferred to first +# adapter use. Importing them eagerly here added ~110ms wall and ~33MB +# RSS to *every* CLI invocation (the plugin loader imports this module at +# ``model_tools`` import time, so ``hermes status``, ``hermes chat``, etc. +# all paid the cost even though they never instantiate the adapter). +# +# All names below are module globals that ``_load_google_modules()`` +# rebinds on first call. The ``HttpError = Exception`` placeholder is +# important: ``except HttpError as exc:`` clauses elsewhere in this +# module bind the *current* module-global at try/except evaluation time, +# so as long as ``_load_google_modules()`` runs before any such +# ``try`` block executes (which it does — ``__init__`` calls it), the +# rebound real ``googleapiclient.errors.HttpError`` is what actually +# matches at runtime. +GOOGLE_CHAT_AVAILABLE: bool = False +httplib2: Any = None # type: ignore +pubsub_v1: Any = None # type: ignore +gax_exceptions: Any = None # type: ignore +service_account: Any = None # type: ignore +AuthorizedHttp: Any = None # type: ignore +build_service: Any = None # type: ignore +HttpError: Any = Exception # type: ignore +MediaFileUpload: Any = None # type: ignore +_google_modules_loaded: bool = False + + +def _load_google_modules() -> bool: + """Lazily import the heavy google-cloud + googleapiclient stack. + + Idempotent. Returns True if the optional deps are installed and + were successfully imported, False otherwise. On success, mutates + the module globals so existing code using ``pubsub_v1``, + ``service_account``, ``HttpError``, etc. transparently uses the + real classes. + + Why deferred: the import chain pulls in google.cloud.pubsub_v1, + googleapiclient, grpc, and friends — about 33MB RSS and 110ms wall + on a fresh interpreter. Plugin discovery imports this module on + every CLI invocation, even ones that never touch a gateway. + """ + global GOOGLE_CHAT_AVAILABLE, _google_modules_loaded + global httplib2, pubsub_v1, gax_exceptions, service_account + global AuthorizedHttp, build_service, HttpError, MediaFileUpload + if _google_modules_loaded: + return GOOGLE_CHAT_AVAILABLE + _google_modules_loaded = True + try: + import httplib2 as _httplib2 + from google.cloud import pubsub_v1 as _pubsub_v1 + from google.api_core import exceptions as _gax_exceptions + from google.oauth2 import service_account as _service_account + from google_auth_httplib2 import AuthorizedHttp as _AuthorizedHttp + from googleapiclient.discovery import build as _build_service + from googleapiclient.errors import HttpError as _HttpError + from googleapiclient.http import MediaFileUpload as _MediaFileUpload + except ImportError: + GOOGLE_CHAT_AVAILABLE = False + return False + httplib2 = _httplib2 + pubsub_v1 = _pubsub_v1 + gax_exceptions = _gax_exceptions + service_account = _service_account + AuthorizedHttp = _AuthorizedHttp + build_service = _build_service + HttpError = _HttpError + MediaFileUpload = _MediaFileUpload GOOGLE_CHAT_AVAILABLE = True -except ImportError: - GOOGLE_CHAT_AVAILABLE = False - httplib2 = None # type: ignore - pubsub_v1 = None # type: ignore - gax_exceptions = None # type: ignore - service_account = None # type: ignore - AuthorizedHttp = None # type: ignore - build_service = None # type: ignore - HttpError = Exception # type: ignore - MediaFileUpload = None # type: ignore + return True from gateway.config import Platform, PlatformConfig @@ -181,8 +229,14 @@ _TYPING_CONSUMED_SENTINEL = "" def check_google_chat_requirements() -> bool: - """Check if Google Chat optional dependencies are installed.""" - return GOOGLE_CHAT_AVAILABLE + """Check if Google Chat optional dependencies are installed. + + Triggers the lazy import of the google-cloud + googleapiclient stack + on first call. Subsequent calls hit the cached result. This is the + canonical "are the deps available" probe used by the plugin registry + and the adapter's own startup gate. + """ + return _load_google_modules() # Hostnames we trust to host Google Chat attachment download URIs. Anything @@ -400,6 +454,16 @@ class GoogleChatAdapter(BasePlatformAdapter): # attribute to ``gateway.config.Platform`` — bundled platform plugins # are looked up by value, not attribute (matches Teams, IRC). super().__init__(config, Platform("google_chat")) + # Trigger the deferred google-cloud + googleapiclient import here so + # that any code path which constructs the adapter and then calls + # methods directly (notably the test suite, which builds an adapter + # and invokes ``_send_file`` / ``_create_message`` / etc. without + # going through ``connect()``) sees real classes for ``MediaFileUpload``, + # ``service_account``, ``HttpError``, and friends. The module-level + # globals were previously eager-imported; making this lazy saved + # ~110ms / ~33MB on every CLI invocation. Idempotent — pays the cost + # exactly once per process. + _load_google_modules() self._subscriber: Optional[Any] = None self._chat_api: Optional[Any] = None # User-authed Chat API client built lazily from the OAuth refresh @@ -685,7 +749,13 @@ class GoogleChatAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ async def connect(self) -> bool: """Validate config, authenticate, start Pub/Sub pull, resolve bot id.""" - if not GOOGLE_CHAT_AVAILABLE: + # First call into the heavy google-cloud stack — trigger the lazy + # import. ``_load_google_modules()`` is idempotent and rebinds the + # module globals (``pubsub_v1``, ``service_account``, ``HttpError``, + # …) used throughout this file. Anything that runs *before* this + # call would see the placeholders, so connect() is the natural + # gate. + if not _load_google_modules(): self._set_fatal_error( code="missing_deps", message="google-cloud-pubsub / google-api-python-client not installed",