fix(gateway): complete lazy-install rebind for slack/feishu/matrix + add ensure_and_bind helper (#25038)

Fixes #25028.

The lazy-install hooks added in #25014 installed packages correctly but
failed to rebind module-level globals after install:

- Slack: missing aiohttp rebind → NameError on file uploads
- Feishu: none of the ~25 lark_oapi symbols rebound → TypeError on
  adapter instantiation
- Matrix: mautrix.types enums stayed as stubs → mismatched values at
  runtime

Introduces tools.lazy_deps.ensure_and_bind() — a DRY helper that
combines ensure() + importer-callable + globals().update(). This
eliminates the error-prone pattern of manually listing every global
that needs updating after lazy-install. Each platform adapter now
defines a single _import() function returning all bindings.

Also fixes: pyproject.toml [slack] extra was missing aiohttp (needed
by slack-bolt's async path).
This commit is contained in:
Siddharth Balyan 2026-05-14 10:41:46 +05:30 committed by GitHub
parent 52521c937a
commit d898e0eb7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 149 additions and 39 deletions

View file

@ -1346,22 +1346,62 @@ def check_feishu_requirements() -> bool:
"""Check if Feishu/Lark dependencies are available. """Check if Feishu/Lark dependencies are available.
Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")`` Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
on first call if not present. on first call if not present. Rebinds all module-level globals on success.
""" """
global FEISHU_AVAILABLE
if FEISHU_AVAILABLE: if FEISHU_AVAILABLE:
return True return True
try:
from tools.lazy_deps import ensure as _lazy_ensure def _import():
_lazy_ensure("platform.feishu", prompt=False) import lark_oapi as lark
except Exception: from lark_oapi.api.application.v6 import GetApplicationRequest
return False from lark_oapi.api.im.v1 import (
try: CreateFileRequest, CreateFileRequestBody,
import lark_oapi # noqa: F401 CreateImageRequest, CreateImageRequestBody,
except ImportError: CreateMessageRequest, CreateMessageRequestBody,
return False GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
FEISHU_AVAILABLE = True P2ImMessageMessageReadV1,
return True 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 {
"lark": lark,
"GetApplicationRequest": GetApplicationRequest,
"CreateFileRequest": CreateFileRequest,
"CreateFileRequestBody": CreateFileRequestBody,
"CreateImageRequest": CreateImageRequest,
"CreateImageRequestBody": CreateImageRequestBody,
"CreateMessageRequest": CreateMessageRequest,
"CreateMessageRequestBody": CreateMessageRequestBody,
"GetChatRequest": GetChatRequest,
"GetMessageRequest": GetMessageRequest,
"GetMessageResourceRequest": GetMessageResourceRequest,
"P2ImMessageMessageReadV1": P2ImMessageMessageReadV1,
"ReplyMessageRequest": ReplyMessageRequest,
"ReplyMessageRequestBody": ReplyMessageRequestBody,
"UpdateMessageRequest": UpdateMessageRequest,
"UpdateMessageRequestBody": UpdateMessageRequestBody,
"AccessTokenType": AccessTokenType,
"HttpMethod": HttpMethod,
"FEISHU_DOMAIN": FEISHU_DOMAIN,
"LARK_DOMAIN": LARK_DOMAIN,
"BaseRequest": BaseRequest,
"CallBackCard": CallBackCard,
"P2CardActionTriggerResponse": P2CardActionTriggerResponse,
"EventDispatcherHandler": EventDispatcherHandler,
"FeishuWSClient": FeishuWSClient,
"FEISHU_AVAILABLE": True,
}
from tools.lazy_deps import ensure_and_bind
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
class FeishuAdapter(BasePlatformAdapter): class FeishuAdapter(BasePlatformAdapter):

View file

@ -227,7 +227,7 @@ def check_matrix_requirements() -> bool:
"""Return True if the Matrix adapter can be used. """Return True if the Matrix adapter can be used.
Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")`` Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")``
on first call if not present. on first call if not present. Rebinds all module-level type globals on success.
""" """
token = os.getenv("MATRIX_ACCESS_TOKEN", "") token = os.getenv("MATRIX_ACCESS_TOKEN", "")
password = os.getenv("MATRIX_PASSWORD", "") password = os.getenv("MATRIX_PASSWORD", "")
@ -242,11 +242,27 @@ def check_matrix_requirements() -> bool:
try: try:
import mautrix # noqa: F401 import mautrix # noqa: F401
except ImportError: except ImportError:
try: def _import():
from tools.lazy_deps import ensure as _lazy_ensure from mautrix.types import (
_lazy_ensure("platform.matrix", prompt=False) ContentURI, EventID, EventType, PaginationDirection,
import mautrix # noqa: F401, F811 PresenceState, RoomCreatePreset, RoomID, SyncToken,
except Exception: TrustState, UserID,
)
return {
"ContentURI": ContentURI,
"EventID": EventID,
"EventType": EventType,
"PaginationDirection": PaginationDirection,
"PresenceState": PresenceState,
"RoomCreatePreset": RoomCreatePreset,
"RoomID": RoomID,
"SyncToken": SyncToken,
"TrustState": TrustState,
"UserID": UserID,
}
from tools.lazy_deps import ensure_and_bind
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
logger.warning( logger.warning(
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'" "Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
) )

View file

@ -76,27 +76,26 @@ def check_slack_requirements() -> bool:
"""Check if Slack dependencies are available. """Check if Slack dependencies are available.
Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")`` Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")``
on first call if not present. on first call if not present. Rebinds all module-level globals on success.
""" """
global SLACK_AVAILABLE, AsyncApp, AsyncSocketModeHandler, AsyncWebClient
if SLACK_AVAILABLE: if SLACK_AVAILABLE:
return True return True
try:
from tools.lazy_deps import ensure as _lazy_ensure def _import():
_lazy_ensure("platform.slack", prompt=False) from slack_bolt.async_app import AsyncApp
except Exception: from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
return False from slack_sdk.web.async_client import AsyncWebClient
try: import aiohttp
from slack_bolt.async_app import AsyncApp as _App return {
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler as _Handler "AsyncApp": AsyncApp,
from slack_sdk.web.async_client import AsyncWebClient as _Client "AsyncSocketModeHandler": AsyncSocketModeHandler,
except ImportError: "AsyncWebClient": AsyncWebClient,
return False "aiohttp": aiohttp,
AsyncApp = _App "SLACK_AVAILABLE": True,
AsyncSocketModeHandler = _Handler }
AsyncWebClient = _Client
SLACK_AVAILABLE = True from tools.lazy_deps import ensure_and_bind
return True return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
def _extract_text_from_slack_blocks(blocks: list) -> str: def _extract_text_from_slack_blocks(blocks: list) -> str:

View file

@ -83,7 +83,7 @@ hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
cron = [] # croniter is now a core dependency; this extra kept for back-compat cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"] slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
cli = ["simple-term-menu==1.6.6"] cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"] tts-premium = ["elevenlabs==1.59.0"]

View file

@ -59,7 +59,7 @@ import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Callable, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -440,3 +440,56 @@ def feature_install_command(feature: str) -> Optional[str]:
return None return None
specs = LAZY_DEPS[feature] specs = LAZY_DEPS[feature]
return "uv pip install " + " ".join(repr(s) for s in specs) return "uv pip install " + " ".join(repr(s) for s in specs)
def ensure_and_bind(
feature: str,
importer: Callable[[], dict[str, Any]],
target_globals: dict,
*,
prompt: bool = False,
) -> bool:
"""Ensure a feature is installed, then rebind names into the caller's globals.
Combines :func:`ensure` with a post-install import step that rebinds
module-level names. This eliminates the error-prone pattern of manually
listing every global that needs updating after lazy-install.
``importer`` is a zero-arg callable that returns a dict of
``{name: value}`` for all symbols the caller needs rebound. It is called
only after :func:`ensure` succeeds (or if the packages are already
installed).
Returns True on success, False if deps couldn't be installed or imported.
Example usage in a platform adapter::
def check_slack_requirements() -> bool:
if SLACK_AVAILABLE:
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
"""
try:
ensure(feature, prompt=prompt)
except (FeatureUnavailable, Exception):
return False
try:
bindings = importer()
except ImportError:
return False
target_globals.update(bindings)
return True

2
uv.lock generated
View file

@ -2092,6 +2092,7 @@ rl = [
{ name = "wandb" }, { name = "wandb" },
] ]
slack = [ slack = [
{ name = "aiohttp" },
{ name = "slack-bolt" }, { name = "slack-bolt" },
{ name = "slack-sdk" }, { name = "slack-sdk" },
] ]
@ -2149,6 +2150,7 @@ requires-dist = [
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" },
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" },
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" },