fix(feishu): defer the lark_oapi import off the startup path
Salvage of #57657, ported onto the plugin layout (the adapter moved from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py since the PR's base). lark_oapi takes seconds to import and holds the GIL doing it; the module-level import made every gateway boot pay that cost even with Feishu unconfigured. - _load_lark_oapi() with double-checked locking binds the SDK globals on first use; connect() and _standalone_send() call it via asyncio.to_thread so the loop never blocks on the import. - probe_bot() also calls _load_lark_oapi() (sync context) so the SDK probe path is preserved rather than silently degrading to the HTTP fallback before a first connect. - check_feishu_requirements() is install-only and no longer rebinds globals; test_feishu.py gets a setUpModule that binds them eagerly for tests that inject fake clients. Includes the dedicated lazy-import test file (check-does-not-import, connect-loads-on-worker-thread).
This commit is contained in:
parent
0422479031
commit
b51c4e6a78
|
|
@ -85,45 +85,35 @@ try:
|
|||
except ImportError:
|
||||
websockets = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
CreateImageRequest,
|
||||
CreateImageRequestBody,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
GetChatRequest,
|
||||
GetMessageRequest,
|
||||
GetMessageResourceRequest,
|
||||
P2ImMessageMessageReadV1,
|
||||
ReplyMessageRequest,
|
||||
ReplyMessageRequestBody,
|
||||
UpdateMessageRequest,
|
||||
UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
|
||||
FEISHU_AVAILABLE = True
|
||||
except ImportError:
|
||||
FEISHU_AVAILABLE = False
|
||||
lark = None # type: ignore[assignment]
|
||||
CallBackCard = None # type: ignore[assignment]
|
||||
P2CardActionTriggerResponse = None # type: ignore[assignment]
|
||||
EventDispatcherHandler = None # type: ignore[assignment]
|
||||
FeishuWSClient = None # type: ignore[assignment]
|
||||
FEISHU_DOMAIN = None # type: ignore[assignment]
|
||||
LARK_DOMAIN = None # type: ignore[assignment]
|
||||
# lark_oapi takes a noticeable amount of time to import. Keep the gateway
|
||||
# configuration path responsive by importing it only when Feishu connects.
|
||||
lark = None # type: ignore[assignment]
|
||||
GetApplicationRequest = None # type: ignore[assignment]
|
||||
CreateFileRequest = None # type: ignore[assignment]
|
||||
CreateFileRequestBody = None # type: ignore[assignment]
|
||||
CreateImageRequest = None # type: ignore[assignment]
|
||||
CreateImageRequestBody = None # type: ignore[assignment]
|
||||
CreateMessageRequest = None # type: ignore[assignment]
|
||||
CreateMessageRequestBody = None # type: ignore[assignment]
|
||||
GetChatRequest = None # type: ignore[assignment]
|
||||
GetMessageRequest = None # type: ignore[assignment]
|
||||
GetMessageResourceRequest = None # type: ignore[assignment]
|
||||
P2ImMessageMessageReadV1 = None # type: ignore[assignment]
|
||||
ReplyMessageRequest = None # type: ignore[assignment]
|
||||
ReplyMessageRequestBody = None # type: ignore[assignment]
|
||||
UpdateMessageRequest = None # type: ignore[assignment]
|
||||
UpdateMessageRequestBody = None # type: ignore[assignment]
|
||||
AccessTokenType = None # type: ignore[assignment]
|
||||
HttpMethod = None # type: ignore[assignment]
|
||||
FEISHU_DOMAIN = None # type: ignore[assignment]
|
||||
LARK_DOMAIN = None # type: ignore[assignment]
|
||||
BaseRequest = None # type: ignore[assignment]
|
||||
CallBackCard = None # type: ignore[assignment]
|
||||
P2CardActionTriggerResponse = None # type: ignore[assignment]
|
||||
EventDispatcherHandler = None # type: ignore[assignment]
|
||||
FeishuWSClient = None # type: ignore[assignment]
|
||||
FEISHU_AVAILABLE = False
|
||||
_lark_import_lock = threading.Lock()
|
||||
|
||||
FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
|
||||
FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
|
||||
|
|
@ -1395,36 +1385,38 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
|||
adapter._ws_thread_loop = None
|
||||
|
||||
|
||||
def check_feishu_requirements() -> bool:
|
||||
"""Check if Feishu/Lark dependencies are available.
|
||||
|
||||
Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
|
||||
on first call if not present. Rebinds all module-level globals on success.
|
||||
"""
|
||||
def _load_lark_oapi() -> bool:
|
||||
"""Import and bind the Feishu SDK after an explicit connection request."""
|
||||
if FEISHU_AVAILABLE:
|
||||
return True
|
||||
|
||||
def _import():
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest, CreateFileRequestBody,
|
||||
CreateImageRequest, CreateImageRequestBody,
|
||||
CreateMessageRequest, CreateMessageRequestBody,
|
||||
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
|
||||
P2ImMessageMessageReadV1,
|
||||
ReplyMessageRequest, ReplyMessageRequestBody,
|
||||
UpdateMessageRequest, UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard, P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
return {
|
||||
with _lark_import_lock:
|
||||
if FEISHU_AVAILABLE:
|
||||
return True
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest, CreateFileRequestBody,
|
||||
CreateImageRequest, CreateImageRequestBody,
|
||||
CreateMessageRequest, CreateMessageRequestBody,
|
||||
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
|
||||
P2ImMessageMessageReadV1,
|
||||
ReplyMessageRequest, ReplyMessageRequestBody,
|
||||
UpdateMessageRequest, UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard, P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
globals().update({
|
||||
"lark": lark,
|
||||
"GetApplicationRequest": GetApplicationRequest,
|
||||
"CreateFileRequest": CreateFileRequest,
|
||||
|
|
@ -1451,10 +1443,22 @@ def check_feishu_requirements() -> bool:
|
|||
"EventDispatcherHandler": EventDispatcherHandler,
|
||||
"FeishuWSClient": FeishuWSClient,
|
||||
"FEISHU_AVAILABLE": True,
|
||||
}
|
||||
})
|
||||
return True
|
||||
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
|
||||
|
||||
def check_feishu_requirements() -> bool:
|
||||
"""Ensure Feishu dependencies are installed without importing the SDK."""
|
||||
if FEISHU_AVAILABLE:
|
||||
return True
|
||||
|
||||
from tools.lazy_deps import ensure
|
||||
|
||||
try:
|
||||
ensure("platform.feishu", prompt=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class FeishuAdapter(BasePlatformAdapter):
|
||||
|
|
@ -1752,9 +1756,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
# A fresh connect (or reconnect) re-arms the SDK executor after a prior
|
||||
# disconnect set the closing flag.
|
||||
self._sdk_executor_closing = False
|
||||
if not FEISHU_AVAILABLE:
|
||||
logger.error("[Feishu] lark-oapi not installed")
|
||||
return False
|
||||
if not self._app_id or not self._app_secret:
|
||||
logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set")
|
||||
return False
|
||||
|
|
@ -1769,6 +1770,9 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
"[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY."
|
||||
)
|
||||
return False
|
||||
if not await asyncio.to_thread(_load_lark_oapi):
|
||||
logger.error("[Feishu] lark-oapi not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._app_lock_identity = self._app_id
|
||||
|
|
@ -5410,7 +5414,10 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
|||
Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID
|
||||
that Feishu puts in @mention payloads. It is NOT the app_id.
|
||||
"""
|
||||
if FEISHU_AVAILABLE:
|
||||
# The SDK import is deferred until connect(); onboarding runs before any
|
||||
# connect, so load it here to keep the SDK probe path reachable rather
|
||||
# than silently degrading every setup run to the HTTP fallback.
|
||||
if _load_lark_oapi():
|
||||
return _probe_bot_sdk(app_id, app_secret, domain)
|
||||
return _probe_bot_http(app_id, app_secret, domain)
|
||||
|
||||
|
|
@ -5598,7 +5605,7 @@ async def _standalone_send(
|
|||
FeishuAdapter, hydrates its lark client, and sends text + native media
|
||||
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
|
||||
"""
|
||||
if not FEISHU_AVAILABLE:
|
||||
if not await asyncio.to_thread(_load_lark_oapi):
|
||||
return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."}
|
||||
|
||||
media_files = media_files or []
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ except ImportError:
|
|||
_HAS_LARK_OAPI = False
|
||||
|
||||
|
||||
def setUpModule():
|
||||
"""Bind the lark SDK globals before tests that inject a mock ``_client``.
|
||||
|
||||
The adapter defers the lark import to ``connect()`` (``_load_lark_oapi``),
|
||||
so tests that skip connect() and set ``adapter._client`` directly would
|
||||
otherwise see the request-builder globals still bound to None.
|
||||
"""
|
||||
if _HAS_LARK_OAPI:
|
||||
from plugins.platforms.feishu.adapter import _load_lark_oapi
|
||||
_load_lark_oapi()
|
||||
|
||||
|
||||
class _FakeRequestContent:
|
||||
def __init__(self, body: bytes):
|
||||
self.body = body
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"""Regression coverage for deferred Feishu SDK loading."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
def _feishu_adapter_module():
|
||||
"""Import the adapter with the Windows app-data root available in CI."""
|
||||
with patch.dict(os.environ, {"LOCALAPPDATA": tempfile.gettempdir()}):
|
||||
from plugins.platforms.feishu import adapter
|
||||
|
||||
return adapter
|
||||
|
||||
|
||||
def test_configured_feishu_dependency_check_does_not_load_sdk():
|
||||
"""Gateway configuration can validate Feishu without importing its SDK."""
|
||||
feishu_adapter = _feishu_adapter_module()
|
||||
|
||||
with (
|
||||
patch.object(feishu_adapter, "FEISHU_AVAILABLE", False),
|
||||
patch("tools.lazy_deps.ensure", autospec=True) as ensure,
|
||||
):
|
||||
assert feishu_adapter.check_feishu_requirements() is True
|
||||
assert feishu_adapter.FEISHU_AVAILABLE is False
|
||||
|
||||
ensure.assert_called_once_with("platform.feishu", prompt=False)
|
||||
|
||||
|
||||
def test_feishu_connect_loads_sdk_on_worker_thread():
|
||||
"""The first SDK import is deferred until a configured adapter connects."""
|
||||
from gateway.config import PlatformConfig
|
||||
feishu_adapter = _feishu_adapter_module()
|
||||
|
||||
adapter = feishu_adapter.FeishuAdapter(
|
||||
PlatformConfig(
|
||||
extra={
|
||||
"app_id": "cli_test",
|
||||
"app_secret": "secret_test",
|
||||
"connection_mode": "websocket",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(feishu_adapter, "FEISHU_AVAILABLE", False),
|
||||
patch.object(feishu_adapter, "_load_lark_oapi", return_value=True) as load_sdk,
|
||||
patch.object(feishu_adapter.asyncio, "to_thread", new_callable=AsyncMock, return_value=True) as to_thread,
|
||||
patch.object(adapter, "_connect_with_retry", new_callable=AsyncMock),
|
||||
patch.object(feishu_adapter, "acquire_scoped_lock", return_value=(True, {})),
|
||||
):
|
||||
assert asyncio.run(adapter.connect()) is True
|
||||
|
||||
to_thread.assert_awaited_once_with(load_sdk)
|
||||
Loading…
Reference in New Issue