mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
fix(desktop): fetch trimmed lark_oapi bundle instead of pip install
This commit is contained in:
1 parent
d0e292ae77
commit
2e7a242196
10 files changed
+655
-158
No files matched your search
@@ -0,0 +1,68 @@
|
|||||||
|
name: Build Feishu SDK Bundle
|
||||||
|
|
||||||
|
# The desktop build ships without lark_oapi; clients download a trimmed bundle
|
||||||
|
# the first time a user enables Feishu. This workflow builds that bundle
|
||||||
|
# reproducibly and smoke-tests it.
|
||||||
|
#
|
||||||
|
# Run it only when VENDOR_VERSION in channel/feishu/lark_install.py changes:
|
||||||
|
# the artifact is published once per SDK version and reused from then on.
|
||||||
|
# The bundle is pure Python, so one artifact serves every platform.
|
||||||
|
#
|
||||||
|
# Publishing is a manual step -- download the artifact from this run and put it
|
||||||
|
# on the CDN, then pin the printed sha256 in lark_install.py.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "desktop/build/build-feishu-vendor.py"
|
||||||
|
- "channel/feishu/lark_install.py"
|
||||||
|
- ".github/workflows/publish-feishu-vendor.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
# Matches the Python the desktop backend is frozen with.
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Build and smoke-test the bundle
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# The bundle expects these from the desktop build rather than
|
||||||
|
# carrying its own copies; the smoke test needs them too.
|
||||||
|
python -m pip install requests pycryptodome
|
||||||
|
python desktop/build/build-feishu-vendor.py --out dist/feishu-vendor.zip
|
||||||
|
|
||||||
|
- name: Check the pinned checksum matches
|
||||||
|
run: |
|
||||||
|
expected=$(python -c "import re,pathlib; \
|
||||||
|
print(re.search(r'VENDOR_SHA256 = \"([0-9a-f]*)\"', \
|
||||||
|
pathlib.Path('channel/feishu/lark_install.py').read_text()).group(1))")
|
||||||
|
actual=$(sha256sum dist/feishu-vendor.zip | cut -d' ' -f1)
|
||||||
|
echo "pinned in lark_install.py: ${expected:-<unset>}"
|
||||||
|
echo "built artifact : $actual"
|
||||||
|
if [ -n "$expected" ] && [ "$expected" != "$actual" ]; then
|
||||||
|
echo "::error::VENDOR_SHA256 is stale. Set it to: $actual"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Rename artifact to its published name
|
||||||
|
run: |
|
||||||
|
version=$(python -c "import re,pathlib; \
|
||||||
|
print(re.search(r'VENDOR_VERSION = \"([^\"]+)\"', \
|
||||||
|
pathlib.Path('channel/feishu/lark_install.py').read_text()).group(1))")
|
||||||
|
mv dist/feishu-vendor.zip "dist/feishu-vendor-$version.zip"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: feishu-vendor
|
||||||
|
path: dist/feishu-vendor-*.zip
|
||||||
@@ -54,6 +54,7 @@ desktop/build/*
|
|||||||
!desktop/build/cowagent-backend.spec
|
!desktop/build/cowagent-backend.spec
|
||||||
!desktop/build/requirements-desktop.txt
|
!desktop/build/requirements-desktop.txt
|
||||||
!desktop/build/build-backend.sh
|
!desktop/build/build-backend.sh
|
||||||
|
!desktop/build/build-feishu-vendor.py
|
||||||
!desktop/build/entitlements.mac.plist
|
!desktop/build/entitlements.mac.plist
|
||||||
!desktop/build/notarize-dmg.sh
|
!desktop/build/notarize-dmg.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
9f77a3afd2e0488aa6e67cc7aa9c0772d12e1aac56814615ac6d4499c9f1c051
|
||||||
@@ -51,8 +51,8 @@ URL_VERIFICATION = "url_verification"
|
|||||||
|
|
||||||
# Lazy import of the lark_oapi SDK. The full `import lark_oapi` pulls in 10k+
|
# Lazy import of the lark_oapi SDK. The full `import lark_oapi` pulls in 10k+
|
||||||
# files and takes 4-10s, so we defer the actual import to where it is needed.
|
# files and takes 4-10s, so we defer the actual import to where it is needed.
|
||||||
# In desktop mode the SDK is not bundled; the first import triggers an on-demand
|
# In desktop mode the SDK is not bundled; the first import downloads a trimmed
|
||||||
# pip install into a writable per-user dir (see channel/feishu/lark_install.py).
|
# bundle into a writable per-user dir (see channel/feishu/lark_install.py).
|
||||||
from channel.feishu import lark_install
|
from channel.feishu import lark_install
|
||||||
|
|
||||||
lark = None # will be populated on first use via _ensure_lark_imported()
|
lark = None # will be populated on first use via _ensure_lark_imported()
|
||||||
@@ -61,8 +61,8 @@ lark = None # will be populated on first use via _ensure_lark_imported()
|
|||||||
def _ensure_lark_imported():
|
def _ensure_lark_imported():
|
||||||
"""Import lark_oapi on first use (takes 4-10s due to 10k+ source files).
|
"""Import lark_oapi on first use (takes 4-10s due to 10k+ source files).
|
||||||
|
|
||||||
In desktop mode, if the SDK is missing this triggers an on-demand install
|
In desktop mode, if the SDK is missing this fetches it on demand (requires
|
||||||
(requires network the first time) instead of failing outright.
|
network the first time) instead of failing outright.
|
||||||
"""
|
"""
|
||||||
global lark
|
global lark
|
||||||
if lark is None:
|
if lark is None:
|
||||||
|
|||||||
+161
-71
@@ -1,24 +1,37 @@
|
|||||||
"""
|
"""
|
||||||
On-demand installer for the ``lark_oapi`` SDK (Feishu channel).
|
On-demand provisioning of the ``lark_oapi`` SDK (Feishu channel).
|
||||||
|
|
||||||
Why this exists
|
Why this exists
|
||||||
---------------
|
---------------
|
||||||
The desktop client (PyInstaller build) intentionally does **not** bundle
|
The desktop client (PyInstaller build) intentionally does **not** bundle
|
||||||
``lark_oapi`` (~116MB + dependencies) so the base download stays small
|
``lark_oapi``: the published SDK is ~122MB unpacked because it ships models for
|
||||||
(see zhayujie/CowAgent#2987 — "客户端没有飞书通道"). Instead, the first time a
|
all 59 Feishu open-platform domains, which is why the channel was dropped from
|
||||||
user enables the Feishu channel in desktop mode, we pip-install ``lark_oapi``
|
the desktop build (see zhayujie/CowAgent#2987 — "客户端没有飞书通道").
|
||||||
(and its dependencies) into a writable per-user directory and add it to
|
|
||||||
``sys.path``. The module is then imported lazily as before.
|
|
||||||
|
|
||||||
For source / non-desktop installs ``lark_oapi`` is expected to be present
|
Instead the first time a user enables Feishu in desktop mode we fetch a trimmed,
|
||||||
already (it lives in ``requirements.txt``); in that case this module is a
|
pure-Python bundle (~1MB) built by ``desktop/build/build-feishu-vendor.py``,
|
||||||
no-op that simply imports it.
|
unpack it into a writable per-user directory and put that on ``sys.path``. The
|
||||||
|
bundle is published once per SDK version and mirrored (see ``VENDOR_URLS``).
|
||||||
|
|
||||||
|
Running ``pip`` at this point is not an option: the frozen app has no real
|
||||||
|
Python interpreter — ``sys.executable`` is the bundled backend itself — so
|
||||||
|
``sys.executable -m pip`` does not behave like pip. Unpacking a prebuilt archive
|
||||||
|
needs nothing but the import machinery, which works normally in a frozen build.
|
||||||
|
|
||||||
|
For source / non-desktop installs ``lark_oapi`` comes from ``requirements.txt``
|
||||||
|
and this module simply confirms it is importable.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -28,90 +41,167 @@ except Exception: # pragma: no cover - common may be unavailable in isolation
|
|||||||
LARK_PKG = "lark-oapi"
|
LARK_PKG = "lark-oapi"
|
||||||
LARK_MIN = "1.5.5"
|
LARK_MIN = "1.5.5"
|
||||||
|
|
||||||
|
# Bumped whenever a new bundle is published: "<lark version>-<bundle revision>".
|
||||||
|
VENDOR_VERSION = "1.7.1-1"
|
||||||
|
|
||||||
|
# sha256 of the published archive. The builder emits a byte-identical zip for a
|
||||||
|
# given version, so this pin is stable; it is what makes the download safe to
|
||||||
|
# trust. Leave empty only for local experiments (verification is then skipped
|
||||||
|
# with a warning).
|
||||||
|
VENDOR_SHA256 = "a96de70291e43b4829a5f717035806835f116bf4dc1d0a2d2ed551908a825381"
|
||||||
|
|
||||||
|
# Tried in order; the same mirrors the desktop installers use. Feishu is a
|
||||||
|
# China-only product, so the domestic CDN comes first. Any mirror is safe
|
||||||
|
# because the payload is checked against VENDOR_SHA256.
|
||||||
|
VENDOR_URLS = (
|
||||||
|
"https://cdn.link-ai.tech/desktop/vendor/feishu-vendor-{version}.zip",
|
||||||
|
"https://cdn.cowagent.ai/desktop/vendor/feishu-vendor-{version}.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT = 120
|
||||||
|
|
||||||
# Per-user, writable, persistent location. Mirrors the browser tool's ~/.cow
|
# Per-user, writable, persistent location. Mirrors the browser tool's ~/.cow
|
||||||
# layout so everything CowAgent owns lives under one roof.
|
# layout so everything CowAgent owns lives under one roof.
|
||||||
_VENDOR_SUBDIR = os.path.join(".cow", "feishu_vendor")
|
_VENDOR_SUBDIR = os.path.join(".cow", "feishu_vendor")
|
||||||
|
|
||||||
|
|
||||||
|
def _install_error(reason: str = "") -> ImportError:
|
||||||
|
detail = f" ({reason})" if reason else ""
|
||||||
|
return ImportError(
|
||||||
|
f"lark_oapi is required for the Feishu channel but is not available{detail}. "
|
||||||
|
f"Install it with: pip install -U '{LARK_PKG}>={LARK_MIN}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def vendor_dir() -> str:
|
def vendor_dir() -> str:
|
||||||
"""Return (and create) the directory where lark_oapi is installed on demand."""
|
"""Return the directory the trimmed SDK is unpacked into.
|
||||||
|
|
||||||
|
Versioned so a newer bundle never has to overwrite a directory that a
|
||||||
|
running process may still be importing from.
|
||||||
|
"""
|
||||||
base = os.environ.get("COW_DATA_DIR") or os.path.expanduser("~")
|
base = os.environ.get("COW_DATA_DIR") or os.path.expanduser("~")
|
||||||
path = os.path.join(base, _VENDOR_SUBDIR)
|
return os.path.join(base, _VENDOR_SUBDIR, VENDOR_VERSION)
|
||||||
os.makedirs(path, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
def vendor_urls() -> list:
|
||||||
|
"""Candidate download URLs, most preferred first."""
|
||||||
|
override = os.environ.get("COW_FEISHU_VENDOR_URL")
|
||||||
|
if override:
|
||||||
|
return [override]
|
||||||
|
return [url.format(version=VENDOR_VERSION) for url in VENDOR_URLS]
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
"""True if lark_oapi can already be imported."""
|
"""True if lark_oapi can be imported right now."""
|
||||||
return importlib.util.find_spec("lark_oapi") is not None
|
return importlib.util.find_spec("lark_oapi") is not None
|
||||||
|
|
||||||
|
|
||||||
def ensure(allow_install: bool = True) -> None:
|
def _activate(path: str) -> bool:
|
||||||
"""Ensure ``lark_oapi`` can be imported.
|
"""Put an unpacked bundle on sys.path and report whether it took effect."""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return False
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
# find_spec consults cached finders, which were populated before the
|
||||||
|
# directory existed.
|
||||||
|
importlib.invalidate_caches()
|
||||||
|
return is_available()
|
||||||
|
|
||||||
- If already importable, return immediately (source installs, or a previous
|
|
||||||
on-demand install that persisted).
|
def _fetch(url: str) -> bytes:
|
||||||
- In desktop mode (``COW_DESKTOP=1``) and when ``allow_install`` is True,
|
logger.info("[FeiShu] downloading Feishu SDK bundle from %s", url)
|
||||||
pip-install ``lark_oapi`` (and its dependencies) on demand into
|
with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as resp:
|
||||||
:func:`vendor_dir` and add that directory to ``sys.path``.
|
return resp.read()
|
||||||
- Otherwise (non-desktop without the package, or install disabled) raise
|
|
||||||
``ImportError`` with actionable guidance.
|
|
||||||
|
def _verify(payload: bytes) -> None:
|
||||||
|
if not VENDOR_SHA256:
|
||||||
|
logger.warning("[FeiShu] VENDOR_SHA256 is unset; skipping integrity check")
|
||||||
|
return
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
if digest != VENDOR_SHA256:
|
||||||
|
raise ValueError(f"checksum mismatch: expected {VENDOR_SHA256}, got {digest}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract(payload: bytes, dest: str) -> None:
|
||||||
|
"""Unpack the archive, refusing entries that escape ``dest``."""
|
||||||
|
with zipfile.ZipFile(io.BytesIO(payload)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
target = os.path.realpath(os.path.join(dest, name))
|
||||||
|
if not target.startswith(os.path.realpath(dest) + os.sep):
|
||||||
|
raise ValueError(f"refusing to extract outside the vendor dir: {name}")
|
||||||
|
zf.extractall(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def _download() -> bytes:
|
||||||
|
"""Fetch the bundle, falling back through the mirrors on failure."""
|
||||||
|
last = None
|
||||||
|
for url in vendor_urls():
|
||||||
|
try:
|
||||||
|
payload = _fetch(url)
|
||||||
|
_verify(payload)
|
||||||
|
return payload
|
||||||
|
except Exception as exc:
|
||||||
|
last = exc
|
||||||
|
logger.warning("[FeiShu] could not fetch the bundle from %s: %s", url, exc)
|
||||||
|
raise last
|
||||||
|
|
||||||
|
|
||||||
|
def _provision(target: str) -> None:
|
||||||
|
"""Download and unpack the bundle into ``target`` atomically."""
|
||||||
|
parent = os.path.dirname(target)
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
payload = _download()
|
||||||
|
|
||||||
|
staging = tempfile.mkdtemp(prefix=".incomplete-", dir=parent)
|
||||||
|
try:
|
||||||
|
_safe_extract(payload, staging)
|
||||||
|
try:
|
||||||
|
os.rename(staging, target)
|
||||||
|
except OSError:
|
||||||
|
# Another process won the race, or the platform refuses to rename
|
||||||
|
# onto an existing directory; its copy is equivalent.
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
raise
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
except BaseException:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def ensure(allow_install: bool = True) -> None:
|
||||||
|
"""Make ``lark_oapi`` importable, fetching the bundle if needed.
|
||||||
|
|
||||||
|
- Already importable (source install, or a previous download): return.
|
||||||
|
- Desktop mode with ``allow_install``: download the trimmed bundle and add
|
||||||
|
it to ``sys.path``.
|
||||||
|
- Otherwise: raise ``ImportError`` with actionable guidance.
|
||||||
"""
|
"""
|
||||||
# Fast path: already importable.
|
|
||||||
if is_available():
|
if is_available():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
target = vendor_dir()
|
||||||
|
# A previous run may have already unpacked it.
|
||||||
|
if _activate(target):
|
||||||
|
return
|
||||||
|
|
||||||
if not allow_install:
|
if not allow_install:
|
||||||
raise ImportError(
|
raise _install_error()
|
||||||
"lark_oapi is required for the Feishu channel but is not installed. "
|
|
||||||
"Install it with: pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
|
||||||
)
|
|
||||||
|
|
||||||
# Source / non-desktop installs should already have it via requirements.txt.
|
# Source / non-desktop installs get it from requirements.txt.
|
||||||
if os.environ.get("COW_DESKTOP") != "1":
|
if os.environ.get("COW_DESKTOP") != "1":
|
||||||
raise ImportError(
|
raise _install_error()
|
||||||
"lark_oapi is required for the Feishu channel but is not installed. "
|
|
||||||
"Install it with: pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
|
||||||
)
|
|
||||||
|
|
||||||
# Desktop mode: install on demand into the user-writable vendor dir.
|
|
||||||
vdir = vendor_dir()
|
|
||||||
if vdir not in sys.path:
|
|
||||||
sys.path.insert(0, vdir)
|
|
||||||
# Re-check: a previous run may have already installed it here.
|
|
||||||
if is_available():
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[FeiShu] lark_oapi not found; installing on demand into %s "
|
"[FeiShu] Feishu SDK not present; fetching the bundle into %s. "
|
||||||
"(requires network, ~%s+). This only happens once.",
|
"This happens once and needs a network connection.", target,
|
||||||
vdir, LARK_MIN,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
_provision(target)
|
||||||
[
|
except Exception as exc:
|
||||||
sys.executable, "-m", "pip", "install",
|
logger.error("[FeiShu] failed to provision the Feishu SDK bundle: %s", exc)
|
||||||
"--target", vdir,
|
raise _install_error(str(exc)) from exc
|
||||||
"--upgrade",
|
|
||||||
"lark-oapi>=%s" % LARK_MIN,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=600,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - surface a clear, actionable error
|
|
||||||
logger.error("[FeiShu] on-demand lark_oapi install failed: %s", exc)
|
|
||||||
raise ImportError(
|
|
||||||
"Failed to install lark_oapi on demand. A network connection is "
|
|
||||||
"required the first time you enable Feishu. Please connect to the "
|
|
||||||
"internet and retry, or install it manually with: "
|
|
||||||
"pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not is_available():
|
if not _activate(target):
|
||||||
raise ImportError(
|
raise _install_error("bundle unpacked but lark_oapi is still not importable")
|
||||||
"lark_oapi could not be imported after on-demand installation. "
|
logger.info("[FeiShu] Feishu SDK bundle ready at %s", target)
|
||||||
"Try installing manually: pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
|
||||||
)
|
|
||||||
logger.info("[FeiShu] lark_oapi installed on demand into %s", vdir)
|
|
||||||
@@ -4319,8 +4319,8 @@ class FeishuRegisterHandler:
|
|||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
# Desktop builds don't bundle lark_oapi; install it on demand
|
# Desktop builds don't bundle lark_oapi; fetch it on demand the
|
||||||
# the first time the user enables Feishu (requires network).
|
# first time the user enables Feishu (requires network).
|
||||||
from channel.feishu import lark_install
|
from channel.feishu import lark_install
|
||||||
lark_install.ensure(allow_install=True)
|
lark_install.ensure(allow_install=True)
|
||||||
import lark_oapi as lark
|
import lark_oapi as lark
|
||||||
@@ -4328,7 +4328,7 @@ class FeishuRegisterHandler:
|
|||||||
with cls._lock:
|
with cls._lock:
|
||||||
cls._state["status"] = "error"
|
cls._state["status"] = "error"
|
||||||
cls._state["error"] = (
|
cls._state["error"] = (
|
||||||
"lark-oapi SDK 未安装或安装失败,请联网后重试,"
|
"飞书 SDK 不可用,请联网后重试,"
|
||||||
"或手动执行 pip install -U 'lark-oapi>=1.5.5'(%s)" % e
|
"或手动执行 pip install -U 'lark-oapi>=1.5.5'(%s)" % e
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the trimmed ``lark_oapi`` bundle downloaded on demand by the desktop app.
|
||||||
|
|
||||||
|
The published SDK ships every Feishu open-platform domain (59 of them, ~122MB
|
||||||
|
unpacked / ~32MB zipped). The Feishu channel only needs the websocket long
|
||||||
|
connection, the event dispatcher and the ``im`` event models -- messages are
|
||||||
|
sent through the REST API with plain ``requests`` -- so almost all of that is
|
||||||
|
dead weight.
|
||||||
|
|
||||||
|
This script installs the SDK with a pinned dependency set, strips it down to
|
||||||
|
what the channel actually imports, and emits a pure-Python zip (~1MB) that works
|
||||||
|
on every platform and Python version.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python desktop/build/build-feishu-vendor.py --out dist/feishu-vendor.zip
|
||||||
|
|
||||||
|
Print the resulting sha256 into ``channel/feishu/lark_install.py`` when
|
||||||
|
publishing a new bundle.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Pinned because the trimming below patches SDK internals; bumping the SDK means
|
||||||
|
# re-running this script and re-checking the smoke test.
|
||||||
|
LARK_VERSION = "1.7.1"
|
||||||
|
|
||||||
|
# The only API domain the channel imports (lark.im.v1.P2ImMessage*).
|
||||||
|
KEEP_API = {"im"}
|
||||||
|
|
||||||
|
# Installed with --no-deps and pinned exactly, because the sha256 recorded in
|
||||||
|
# lark_install.py has to survive rebuilds. Letting pip resolve the tree instead
|
||||||
|
# ties the artifact to the building interpreter (anyio only wants
|
||||||
|
# typing_extensions below Python 3.13) and to whatever was released that day.
|
||||||
|
#
|
||||||
|
# requests, urllib3 and pycryptodome are deliberately missing: the desktop build
|
||||||
|
# imports them directly, and pycryptodome is a native extension this bundle
|
||||||
|
# cannot carry anyway. The rest travel along so the bundle also works outside a
|
||||||
|
# frozen app -- inside one, PyInstaller's importer takes precedence over
|
||||||
|
# sys.path, so the frozen copies keep winning and versions cannot drift apart.
|
||||||
|
REQUIREMENTS = [
|
||||||
|
f"lark-oapi=={LARK_VERSION}",
|
||||||
|
"anyio==4.14.2",
|
||||||
|
"h11==0.16.0",
|
||||||
|
"httpcore==1.0.9",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"requests-toolbelt==1.0.0",
|
||||||
|
"typing_extensions==4.16.0",
|
||||||
|
"websockets==15.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[feishu-vendor] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def pip_install(target):
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", "--target", target,
|
||||||
|
"--no-compile", "--no-deps", *REQUIREMENTS],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_api_domains(pkg):
|
||||||
|
"""Delete every API domain except KEEP_API and rewrite the eager __init__."""
|
||||||
|
api_dir = os.path.join(pkg, "api")
|
||||||
|
removed = 0
|
||||||
|
for name in sorted(os.listdir(api_dir)):
|
||||||
|
path = os.path.join(api_dir, name)
|
||||||
|
if os.path.isdir(path) and name not in KEEP_API and name != "__pycache__":
|
||||||
|
shutil.rmtree(path)
|
||||||
|
removed += 1
|
||||||
|
with open(os.path.join(api_dir, "__init__.py"), "w", encoding="utf-8") as f:
|
||||||
|
for name in sorted(KEEP_API):
|
||||||
|
f.write(f"from . import {name}\n")
|
||||||
|
log(f"dropped {removed} api domains, kept {sorted(KEEP_API)}")
|
||||||
|
|
||||||
|
|
||||||
|
def stub_client(pkg):
|
||||||
|
"""Replace lark.Client, which wires up 57 services the channel never calls."""
|
||||||
|
with open(os.path.join(pkg, "client.py"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
'"""Stub: lark.Client is not part of the trimmed bundle."""\n\n\n'
|
||||||
|
"_MSG = (\n"
|
||||||
|
' "lark_oapi.Client is not available in the trimmed Feishu bundle; "\n'
|
||||||
|
' "call the Feishu REST API directly."\n'
|
||||||
|
")\n\n\n"
|
||||||
|
"class Client:\n"
|
||||||
|
" def __init__(self, *args, **kwargs):\n"
|
||||||
|
" raise NotImplementedError(_MSG)\n\n"
|
||||||
|
" @staticmethod\n"
|
||||||
|
" def builder(*args, **kwargs):\n"
|
||||||
|
" raise NotImplementedError(_MSG)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_dispatcher(pkg):
|
||||||
|
"""Keep only the processors of the surviving domains.
|
||||||
|
|
||||||
|
The builder declares one ``register_*`` method per event type, annotated
|
||||||
|
with model classes from every domain. Annotations are evaluated when the
|
||||||
|
class body runs, so the dropped names would raise NameError; PEP 563 defers
|
||||||
|
them to strings. The method bodies still reference dropped processors, but
|
||||||
|
they only run when called, which never happens for dropped domains.
|
||||||
|
"""
|
||||||
|
path = os.path.join(pkg, "event", "dispatcher_handler.py")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
|
||||||
|
def keep(line):
|
||||||
|
m = re.match(r"from lark_oapi\.api\.(\w+)\.", line)
|
||||||
|
return m is None or m.group(1) in KEEP_API
|
||||||
|
|
||||||
|
kept = [l for l in lines if keep(l)]
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("from __future__ import annotations\n" + "\n".join(kept) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_binaries_and_caches(root):
|
||||||
|
"""Remove __pycache__ and native extensions.
|
||||||
|
|
||||||
|
The only compiled module in the pinned set is the optional ``websockets``
|
||||||
|
speedup, which falls back to pure Python. Dropping it makes the bundle
|
||||||
|
independent of platform and Python version, so one artifact serves everyone.
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
for d in list(dirnames):
|
||||||
|
if d == "__pycache__":
|
||||||
|
shutil.rmtree(os.path.join(dirpath, d))
|
||||||
|
dirnames.remove(d)
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.endswith((".so", ".pyd", ".dylib")):
|
||||||
|
os.remove(os.path.join(dirpath, fn))
|
||||||
|
removed += 1
|
||||||
|
log(f"stripped {removed} native extension(s) -> pure Python")
|
||||||
|
|
||||||
|
|
||||||
|
# Exercises every lark_oapi symbol channel/feishu/feishu_channel.py touches,
|
||||||
|
# so trimming mistakes surface here instead of at runtime.
|
||||||
|
SMOKE_TEST = r'''
|
||||||
|
import json
|
||||||
|
import lark_oapi as lark
|
||||||
|
|
||||||
|
assert VENDOR in lark.__file__, "loaded the wrong lark_oapi: " + lark.__file__
|
||||||
|
|
||||||
|
lark.im.v1.P2ImMessageReceiveV1
|
||||||
|
lark.im.v1.P2ImMessageRecalledV1
|
||||||
|
lark.JSON.marshal({"a": 1})
|
||||||
|
lark.LogLevel.WARNING
|
||||||
|
assert callable(lark.register_app)
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||||
|
P2CardActionTriggerResponse,
|
||||||
|
)
|
||||||
|
P2CardActionTriggerResponse({"toast": {"type": "info", "content": "ok"}})
|
||||||
|
|
||||||
|
handler = (
|
||||||
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
|
.register_p2_im_message_receive_v1(lambda d: None)
|
||||||
|
.register_p2_im_message_recalled_v1(lambda d: None)
|
||||||
|
.register_p2_card_action_trigger(lambda d: None)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
lark.ws.Client("id", "secret", event_handler=handler, log_level=lark.LogLevel.WARNING)
|
||||||
|
|
||||||
|
import lark_oapi.ws.client as ws_mod
|
||||||
|
assert hasattr(ws_mod, "loop")
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
(
|
||||||
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
|
.register_p2_im_message_receive_v1(
|
||||||
|
lambda d: seen.update(json.loads(lark.JSON.marshal(d)))
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)._do_without_validation(json.dumps({
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {"event_id": "e", "event_type": "im.message.receive_v1", "token": "",
|
||||||
|
"create_time": "0", "tenant_key": "t", "app_id": "a"},
|
||||||
|
"event": {"sender": {"sender_id": {"open_id": "ou_x"}, "sender_type": "user"},
|
||||||
|
"message": {"message_id": "om_x", "chat_id": "oc_x", "chat_type": "p2p",
|
||||||
|
"message_type": "text", "content": "{}", "create_time": "0"}},
|
||||||
|
}).encode())
|
||||||
|
assert seen["event"]["message"]["message_id"] == "om_x"
|
||||||
|
print("OK")
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def verify(zip_path):
|
||||||
|
"""Import the bundle in a subprocess and exercise the channel's API surface."""
|
||||||
|
workdir = tempfile.mkdtemp(prefix="feishu-vendor-check-")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(workdir)
|
||||||
|
env = dict(os.environ, PYTHONPATH=workdir)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-c", f"VENDOR = {workdir!r}\n" + SMOKE_TEST],
|
||||||
|
env=env, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log("smoke test FAILED:\n" + proc.stdout + proc.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
log("smoke test passed")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build(out_path):
|
||||||
|
staging = tempfile.mkdtemp(prefix="feishu-vendor-")
|
||||||
|
try:
|
||||||
|
log(f"installing lark-oapi=={LARK_VERSION}")
|
||||||
|
pip_install(staging)
|
||||||
|
|
||||||
|
pkg = os.path.join(staging, "lark_oapi")
|
||||||
|
trim_api_domains(pkg)
|
||||||
|
stub_client(pkg)
|
||||||
|
trim_dispatcher(pkg)
|
||||||
|
|
||||||
|
# Optional high-level layer, never imported by the channel.
|
||||||
|
shutil.rmtree(os.path.join(pkg, "channel"), ignore_errors=True)
|
||||||
|
# Metadata mostly indexes files that no longer exist after trimming.
|
||||||
|
for entry in os.listdir(staging):
|
||||||
|
if entry.endswith((".dist-info", ".egg-info")):
|
||||||
|
shutil.rmtree(os.path.join(staging, entry), ignore_errors=True)
|
||||||
|
|
||||||
|
# Console scripts installed alongside the packages; nothing imports them.
|
||||||
|
shutil.rmtree(os.path.join(staging, "bin"), ignore_errors=True)
|
||||||
|
strip_binaries_and_caches(staging)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
||||||
|
# Sorted entries and a fixed timestamp keep the archive byte-identical
|
||||||
|
# across rebuilds, so the sha256 pinned in lark_install.py stays valid.
|
||||||
|
members = []
|
||||||
|
for dirpath, _, filenames in os.walk(staging):
|
||||||
|
for fn in filenames:
|
||||||
|
full = os.path.join(dirpath, fn)
|
||||||
|
members.append((os.path.relpath(full, staging), full))
|
||||||
|
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for arcname, full in sorted(members):
|
||||||
|
info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
info.external_attr = 0o644 << 16
|
||||||
|
with open(full, "rb") as fh:
|
||||||
|
zf.writestr(info, fh.read())
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
with open(out_path, "rb") as fh:
|
||||||
|
digest = hashlib.sha256(fh.read()).hexdigest()
|
||||||
|
size_mb = os.path.getsize(out_path) / 1024 / 1024
|
||||||
|
log(f"wrote {out_path} ({size_mb:.1f}MB)")
|
||||||
|
log(f"sha256 = {digest}")
|
||||||
|
log("update VENDOR_SHA256 in channel/feishu/lark_install.py with the value above")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--out", default="dist/feishu-vendor.zip")
|
||||||
|
ap.add_argument("--skip-verify", action="store_true",
|
||||||
|
help="skip the post-build smoke test")
|
||||||
|
args = ap.parse_args()
|
||||||
|
build(args.out)
|
||||||
|
if not args.skip_verify:
|
||||||
|
verify(args.out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -29,10 +29,9 @@ def rp(*parts):
|
|||||||
# PyInstaller's static analysis can't see them. List every channel we ship.
|
# PyInstaller's static analysis can't see them. List every channel we ship.
|
||||||
# Feishu (channel.feishu.feishu_channel) is bundled so the channel code is
|
# Feishu (channel.feishu.feishu_channel) is bundled so the channel code is
|
||||||
# present; its heavy lark_oapi SDK is deliberately NOT bundled — see the
|
# present; its heavy lark_oapi SDK is deliberately NOT bundled — see the
|
||||||
# `excludes` note below and channel/feishu/lark_install.py, which installs it
|
# `excludes` note below and channel/feishu/lark_install.py, which downloads a
|
||||||
# on demand the first time the user enables Feishu (fixes
|
# trimmed bundle the first time the user enables Feishu (fixes
|
||||||
# zhayujie/CowAgent#2987: "客户端没有飞书通道"). pip/setuptools/wheel are
|
# zhayujie/CowAgent#2987: "客户端没有飞书通道").
|
||||||
# bundled (small) so that on-demand install can run inside the frozen app.
|
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
# channels (dynamic import in channel/channel_factory.py)
|
# channels (dynamic import in channel/channel_factory.py)
|
||||||
'channel.web.web_channel',
|
'channel.web.web_channel',
|
||||||
@@ -48,12 +47,6 @@ hiddenimports = [
|
|||||||
'channel.telegram.telegram_channel',
|
'channel.telegram.telegram_channel',
|
||||||
'channel.slack.slack_channel',
|
'channel.slack.slack_channel',
|
||||||
'channel.discord.discord_channel',
|
'channel.discord.discord_channel',
|
||||||
# pip is used by the Feishu on-demand installer (channel/feishu/lark_install.py)
|
|
||||||
# to fetch lark_oapi + its deps inside the frozen desktop app.
|
|
||||||
'pip',
|
|
||||||
'pip._internal',
|
|
||||||
'setuptools',
|
|
||||||
'wheel',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Agent tools and model providers are imported lazily in places; collect their
|
# Agent tools and model providers are imported lazily in places; collect their
|
||||||
@@ -162,17 +155,17 @@ datas += collect_data_files('playwright', include_py_files=True)
|
|||||||
# --- Excludes -------------------------------------------------------------
|
# --- Excludes -------------------------------------------------------------
|
||||||
# Keep the bundle lean: drop tests/docs and dev-only packages.
|
# Keep the bundle lean: drop tests/docs and dev-only packages.
|
||||||
#
|
#
|
||||||
# Feishu's lark_oapi (~116MB + deps) is intentionally EXCLUDED here. The desktop
|
# Feishu's lark_oapi (~122MB unpacked) is intentionally EXCLUDED here. The first
|
||||||
# client does not bundle it; instead, the first time a user enables the Feishu
|
# time a user enables the Feishu channel, channel/feishu/lark_install.py
|
||||||
# channel, channel/feishu/lark_install.py pip-installs lark_oapi (and its deps)
|
# downloads a trimmed, pure-Python bundle (~1MB, built by
|
||||||
# into a writable per-user dir (~/.cow/feishu_vendor) and adds it to sys.path.
|
# desktop/build/build-feishu-vendor.py) into ~/.cow/feishu_vendor and adds it to
|
||||||
# This keeps the base download ~116MB smaller while Feishu still works out of
|
# sys.path. This keeps the base download small while Feishu still works out of
|
||||||
# the box after a one-time, on-demand download (fixes zhayujie/CowAgent#2987).
|
# the box after a one-time fetch (fixes zhayujie/CowAgent#2987).
|
||||||
# pip/setuptools/wheel are NOT excluded (see hiddenimports) so that on-demand
|
|
||||||
# install can run inside the frozen app.
|
|
||||||
excludes = [
|
excludes = [
|
||||||
'lark_oapi', # Feishu SDK — installed on demand, NOT bundled (see above)
|
'lark_oapi', # Feishu SDK — fetched on demand, NOT bundled (see above)
|
||||||
'tests',
|
'tests',
|
||||||
|
'pip',
|
||||||
|
'wheel',
|
||||||
'pytest',
|
'pytest',
|
||||||
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
||||||
# so the browser tool works out of the box on desktop. The heavy Chromium
|
# so the browser tool works out of the box on desktop. The heavy Chromium
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
# (which Electron talks to) plus the agent core; the remaining IM channels are
|
# (which Electron talks to) plus the agent core; the remaining IM channels are
|
||||||
# cheap (~27MB total) so we keep them.
|
# cheap (~27MB total) so we keep them.
|
||||||
#
|
#
|
||||||
# Feishu's `lark-oapi` (~116MB + deps) is NOT listed here on purpose: the desktop
|
# Feishu's `lark-oapi` (~122MB unpacked) is NOT listed here on purpose: the
|
||||||
# client installs it on demand the first time the user enables the Feishu channel
|
# desktop client downloads a trimmed ~1MB bundle the first time the user enables
|
||||||
# (channel/feishu/lark_install.py), keeping the base download ~116MB smaller.
|
# the Feishu channel (channel/feishu/lark_install.py), keeping the base download
|
||||||
# pip/setuptools/wheel are required by that on-demand installer and must be
|
# small. That bundle relies on `requests` and `pycryptodome` below rather than
|
||||||
# present in the build/run environment (they ship inside the frozen app too — see
|
# shipping its own copies.
|
||||||
# the spec's hiddenimports), so they stay available.
|
|
||||||
|
|
||||||
# ---- core ----
|
# ---- core ----
|
||||||
numpy>=1.21
|
numpy>=1.21
|
||||||
@@ -53,7 +52,7 @@ python-pptx
|
|||||||
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||||
playwright==1.52.0
|
playwright==1.52.0
|
||||||
|
|
||||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi is installed on demand
|
# ---- IM channels (kept; lightweight). Feishu/lark-oapi is fetched on demand
|
||||||
# by the desktop client when the user enables the channel (see header note). ----
|
# by the desktop client when the user enables the channel (see header note). ----
|
||||||
wechatpy
|
wechatpy
|
||||||
pycryptodome
|
pycryptodome
|
||||||
@@ -62,7 +61,3 @@ websocket-client>=1.4.0
|
|||||||
python-telegram-bot
|
python-telegram-bot
|
||||||
slack_bolt
|
slack_bolt
|
||||||
discord.py
|
discord.py
|
||||||
# pip/setuptools/wheel back the Feishu on-demand installer; ensure they are present.
|
|
||||||
pip
|
|
||||||
setuptools
|
|
||||||
wheel
|
|
||||||
@@ -1,92 +1,165 @@
|
|||||||
"""Tests for channel.feishu.lark_install (on-demand lark_oapi installer)."""
|
"""Tests for channel.feishu.lark_install (on-demand Feishu SDK bundle)."""
|
||||||
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import io
|
||||||
from unittest import mock
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from channel.feishu import lark_install
|
from channel.feishu import lark_install
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bundle():
|
||||||
|
"""Build an in-memory zip shaped like a vendor bundle."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("lark_oapi/__init__.py", b"# stub\n")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _unpacked(path):
|
||||||
|
"""Stand-in for _activate: the bundle counts as usable once extracted."""
|
||||||
|
return os.path.isdir(os.path.join(path, "lark_oapi"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def clean_vendor(monkeypatch, tmp_path):
|
def offline(monkeypatch, tmp_path):
|
||||||
"""Point the vendor dir at a temp location and drop any lark_oapi from path."""
|
"""Isolate the vendor dir and pretend lark_oapi is not installed."""
|
||||||
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
|
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
|
||||||
monkeypatch.delenv("COW_DESKTOP", raising=False)
|
monkeypatch.delenv("COW_DESKTOP", raising=False)
|
||||||
# Ensure lark_oapi is not importable in this test process.
|
|
||||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
||||||
# Avoid real subprocess / real sys.path pollution.
|
return tmp_path
|
||||||
monkeypatch.setattr(subprocess_run := lark_install.subprocess, "run", mock.MagicMock())
|
|
||||||
yield tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_available_true_when_spec_found(monkeypatch):
|
def test_is_available_reflects_import_machinery(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
importlib.util, "find_spec", lambda name: object() if name == "lark_oapi" else None
|
importlib.util, "find_spec",
|
||||||
|
lambda name: object() if name == "lark_oapi" else None,
|
||||||
)
|
)
|
||||||
assert lark_install.is_available() is True
|
assert lark_install.is_available() is True
|
||||||
assert lark_install.is_available() is False or True # second call path-independent
|
|
||||||
|
|
||||||
|
def test_vendor_dir_is_versioned(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
|
||||||
|
assert lark_install.vendor_dir().endswith(lark_install.VENDOR_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendor_urls_prefer_the_china_mirror(monkeypatch):
|
||||||
|
monkeypatch.delenv("COW_FEISHU_VENDOR_URL", raising=False)
|
||||||
|
urls = lark_install.vendor_urls()
|
||||||
|
assert len(urls) > 1, "expected a fallback mirror"
|
||||||
|
assert "link-ai" in urls[0], "the China CDN should be tried first"
|
||||||
|
assert all(lark_install.VENDOR_VERSION in u for u in urls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendor_url_can_be_overridden(monkeypatch):
|
||||||
|
monkeypatch.setenv("COW_FEISHU_VENDOR_URL", "https://example.test/b.zip")
|
||||||
|
assert lark_install.vendor_urls() == ["https://example.test/b.zip"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_falls_back_to_next_mirror(monkeypatch):
|
||||||
|
payload = _make_bundle()
|
||||||
|
monkeypatch.setattr(lark_install, "VENDOR_SHA256",
|
||||||
|
hashlib.sha256(payload).hexdigest())
|
||||||
|
tried = []
|
||||||
|
|
||||||
|
def flaky(url):
|
||||||
|
tried.append(url)
|
||||||
|
if len(tried) == 1:
|
||||||
|
raise OSError("mirror unreachable")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(lark_install, "_fetch", flaky)
|
||||||
|
assert lark_install._download() == payload
|
||||||
|
assert len(tried) == 2, "should have retried against the fallback mirror"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_raises_when_every_mirror_fails(monkeypatch):
|
||||||
|
def dead(url):
|
||||||
|
raise OSError("unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lark_install, "_fetch", dead)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
lark_install._download()
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_returns_when_already_available(monkeypatch):
|
def test_ensure_returns_when_already_available(monkeypatch):
|
||||||
monkeypatch.setattr(lark_install, "is_available", lambda: True)
|
monkeypatch.setattr(lark_install, "is_available", lambda: True)
|
||||||
# Should not attempt any install.
|
called = []
|
||||||
with mock.patch.object(lark_install.subprocess, "run") as run:
|
monkeypatch.setattr(lark_install, "_provision", lambda t: called.append(t))
|
||||||
lark_install.ensure(allow_install=True)
|
lark_install.ensure(allow_install=True)
|
||||||
run.assert_not_called()
|
assert not called, "should not fetch anything when the SDK is importable"
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_non_desktop_raises(monkeypatch):
|
def test_ensure_non_desktop_raises(offline):
|
||||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
|
||||||
monkeypatch.delenv("COW_DESKTOP", raising=False)
|
|
||||||
with pytest.raises(ImportError):
|
with pytest.raises(ImportError):
|
||||||
lark_install.ensure(allow_install=True)
|
lark_install.ensure(allow_install=True)
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_allow_install_false_raises(monkeypatch):
|
def test_ensure_allow_install_false_raises(offline, monkeypatch):
|
||||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
|
||||||
monkeypatch.setenv("COW_DESKTOP", "1")
|
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||||
with pytest.raises(ImportError):
|
with pytest.raises(ImportError):
|
||||||
lark_install.ensure(allow_install=False)
|
lark_install.ensure(allow_install=False)
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_desktop_installs_on_demand(monkeypatch, tmp_path):
|
def test_ensure_reuses_previously_unpacked_bundle(offline, monkeypatch):
|
||||||
# Simulate: not available before, available after install.
|
"""An existing vendor dir is activated without any download."""
|
||||||
state = {"installed": False}
|
os.makedirs(os.path.join(lark_install.vendor_dir(), "lark_oapi"))
|
||||||
|
|
||||||
def fake_available():
|
def unexpected(target):
|
||||||
return state["installed"]
|
raise AssertionError("should not have downloaded anything")
|
||||||
|
|
||||||
monkeypatch.setattr(lark_install, "is_available", fake_available)
|
monkeypatch.setattr(lark_install, "_provision", unexpected)
|
||||||
monkeypatch.setenv("COW_DESKTOP", "1")
|
monkeypatch.setattr(lark_install, "_activate", _unpacked)
|
||||||
monkeypatch.setattr(lark_install, "vendor_dir", lambda: str(tmp_path))
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
|
||||||
calls.append(cmd)
|
|
||||||
state["installed"] = True # pretend pip succeeded
|
|
||||||
return mock.MagicMock()
|
|
||||||
|
|
||||||
monkeypatch.setattr(lark_install.subprocess, "run", fake_run)
|
|
||||||
|
|
||||||
# Should not raise; install command must target the vendor dir.
|
|
||||||
lark_install.ensure(allow_install=True)
|
lark_install.ensure(allow_install=True)
|
||||||
assert calls, "expected a pip install to have been attempted"
|
|
||||||
cmd = calls[0]
|
|
||||||
assert str(tmp_path) in cmd # --target points at the vendor dir
|
|
||||||
assert "install" in cmd
|
|
||||||
assert any("lark-oapi" in str(part) for part in cmd) # lark-oapi>=1.5.5 spec
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_desktop_install_failure_raises(monkeypatch, tmp_path):
|
def test_ensure_downloads_and_unpacks(offline, monkeypatch):
|
||||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
|
||||||
monkeypatch.setenv("COW_DESKTOP", "1")
|
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||||
monkeypatch.setattr(lark_install, "vendor_dir", lambda: str(tmp_path))
|
payload = _make_bundle()
|
||||||
|
monkeypatch.setattr(lark_install, "VENDOR_SHA256",
|
||||||
|
hashlib.sha256(payload).hexdigest())
|
||||||
|
monkeypatch.setattr(lark_install, "_fetch", lambda url: payload)
|
||||||
|
monkeypatch.setattr(lark_install, "_activate", _unpacked)
|
||||||
|
target = lark_install.vendor_dir()
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
lark_install.ensure(allow_install=True)
|
||||||
raise RuntimeError("no network")
|
|
||||||
|
|
||||||
monkeypatch.setattr(lark_install.subprocess, "run", fake_run)
|
assert os.path.isfile(os.path.join(target, "lark_oapi", "__init__.py"))
|
||||||
|
# No partially extracted staging dirs left behind.
|
||||||
|
leftovers = [d for d in os.listdir(os.path.dirname(target))
|
||||||
|
if d.startswith(".incomplete-")]
|
||||||
|
assert not leftovers
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_rejects_checksum_mismatch(offline, monkeypatch):
|
||||||
|
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||||
|
monkeypatch.setattr(lark_install, "VENDOR_SHA256", "0" * 64)
|
||||||
|
monkeypatch.setattr(lark_install, "_fetch", lambda url: _make_bundle())
|
||||||
|
|
||||||
with pytest.raises(ImportError):
|
with pytest.raises(ImportError):
|
||||||
lark_install.ensure(allow_install=True)
|
lark_install.ensure(allow_install=True)
|
||||||
|
assert not os.path.isdir(lark_install.vendor_dir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_download_failure_raises(offline, monkeypatch):
|
||||||
|
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||||
|
|
||||||
|
def boom(url):
|
||||||
|
raise OSError("no network")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lark_install, "_fetch", boom)
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
lark_install.ensure(allow_install=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_rejects_path_traversal(tmp_path):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("../escaped.py", b"pwned\n")
|
||||||
|
|
||||||
|
dest = tmp_path / "dest"
|
||||||
|
dest.mkdir()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
lark_install._safe_extract(buf.getvalue(), str(dest))
|
||||||
Reference in new issue
Block a user