mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-25 17:36:29 +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/requirements-desktop.txt
|
||||
!desktop/build/build-backend.sh
|
||||
!desktop/build/build-feishu-vendor.py
|
||||
!desktop/build/entitlements.mac.plist
|
||||
!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+
|
||||
# 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
|
||||
# pip install into a writable per-user dir (see channel/feishu/lark_install.py).
|
||||
# In desktop mode the SDK is not bundled; the first import downloads a trimmed
|
||||
# bundle into a writable per-user dir (see channel/feishu/lark_install.py).
|
||||
from channel.feishu import lark_install
|
||||
|
||||
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():
|
||||
"""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
|
||||
(requires network the first time) instead of failing outright.
|
||||
In desktop mode, if the SDK is missing this fetches it on demand (requires
|
||||
network the first time) instead of failing outright.
|
||||
"""
|
||||
global lark
|
||||
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
|
||||
---------------
|
||||
The desktop client (PyInstaller build) intentionally does **not** bundle
|
||||
``lark_oapi`` (~116MB + dependencies) so the base download stays small
|
||||
(see zhayujie/CowAgent#2987 — "客户端没有飞书通道"). Instead, the first time a
|
||||
user enables the Feishu channel in desktop mode, we pip-install ``lark_oapi``
|
||||
(and its dependencies) into a writable per-user directory and add it to
|
||||
``sys.path``. The module is then imported lazily as before.
|
||||
``lark_oapi``: the published SDK is ~122MB unpacked because it ships models for
|
||||
all 59 Feishu open-platform domains, which is why the channel was dropped from
|
||||
the desktop build (see zhayujie/CowAgent#2987 — "客户端没有飞书通道").
|
||||
|
||||
For source / non-desktop installs ``lark_oapi`` is expected to be present
|
||||
already (it lives in ``requirements.txt``); in that case this module is a
|
||||
no-op that simply imports it.
|
||||
Instead the first time a user enables Feishu in desktop mode we fetch a trimmed,
|
||||
pure-Python bundle (~1MB) built by ``desktop/build/build-feishu-vendor.py``,
|
||||
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 io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
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_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
|
||||
# layout so everything CowAgent owns lives under one roof.
|
||||
_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:
|
||||
"""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("~")
|
||||
path = os.path.join(base, _VENDOR_SUBDIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
return os.path.join(base, _VENDOR_SUBDIR, VENDOR_VERSION)
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
|
||||
|
||||
def ensure(allow_install: bool = True) -> None:
|
||||
"""Ensure ``lark_oapi`` can be imported.
|
||||
def _activate(path: str) -> bool:
|
||||
"""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).
|
||||
- In desktop mode (``COW_DESKTOP=1``) and when ``allow_install`` is True,
|
||||
pip-install ``lark_oapi`` (and its dependencies) on demand into
|
||||
:func:`vendor_dir` and add that directory to ``sys.path``.
|
||||
- Otherwise (non-desktop without the package, or install disabled) raise
|
||||
``ImportError`` with actionable guidance.
|
||||
|
||||
def _fetch(url: str) -> bytes:
|
||||
logger.info("[FeiShu] downloading Feishu SDK bundle from %s", url)
|
||||
with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
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():
|
||||
return
|
||||
|
||||
target = vendor_dir()
|
||||
# A previous run may have already unpacked it.
|
||||
if _activate(target):
|
||||
return
|
||||
|
||||
if not allow_install:
|
||||
raise ImportError(
|
||||
"lark_oapi is required for the Feishu channel but is not installed. "
|
||||
"Install it with: pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
||||
)
|
||||
raise _install_error()
|
||||
|
||||
# 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":
|
||||
raise ImportError(
|
||||
"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
|
||||
raise _install_error()
|
||||
|
||||
logger.warning(
|
||||
"[FeiShu] lark_oapi not found; installing on demand into %s "
|
||||
"(requires network, ~%s+). This only happens once.",
|
||||
vdir, LARK_MIN,
|
||||
"[FeiShu] Feishu SDK not present; fetching the bundle into %s. "
|
||||
"This happens once and needs a network connection.", target,
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"--target", vdir,
|
||||
"--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
|
||||
_provision(target)
|
||||
except Exception as exc:
|
||||
logger.error("[FeiShu] failed to provision the Feishu SDK bundle: %s", exc)
|
||||
raise _install_error(str(exc)) from exc
|
||||
|
||||
if not is_available():
|
||||
raise ImportError(
|
||||
"lark_oapi could not be imported after on-demand installation. "
|
||||
"Try installing manually: pip install -U 'lark-oapi>=%s'" % LARK_MIN
|
||||
)
|
||||
logger.info("[FeiShu] lark_oapi installed on demand into %s", vdir)
|
||||
if not _activate(target):
|
||||
raise _install_error("bundle unpacked but lark_oapi is still not importable")
|
||||
logger.info("[FeiShu] Feishu SDK bundle ready at %s", target)
|
||||
@@ -4319,8 +4319,8 @@ class FeishuRegisterHandler:
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
# Desktop builds don't bundle lark_oapi; install it on demand
|
||||
# the first time the user enables Feishu (requires network).
|
||||
# Desktop builds don't bundle lark_oapi; fetch it on demand the
|
||||
# first time the user enables Feishu (requires network).
|
||||
from channel.feishu import lark_install
|
||||
lark_install.ensure(allow_install=True)
|
||||
import lark_oapi as lark
|
||||
@@ -4328,7 +4328,7 @@ class FeishuRegisterHandler:
|
||||
with cls._lock:
|
||||
cls._state["status"] = "error"
|
||||
cls._state["error"] = (
|
||||
"lark-oapi SDK 未安装或安装失败,请联网后重试,"
|
||||
"飞书 SDK 不可用,请联网后重试,"
|
||||
"或手动执行 pip install -U 'lark-oapi>=1.5.5'(%s)" % e
|
||||
)
|
||||
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.
|
||||
# Feishu (channel.feishu.feishu_channel) is bundled so the channel code is
|
||||
# present; its heavy lark_oapi SDK is deliberately NOT bundled — see the
|
||||
# `excludes` note below and channel/feishu/lark_install.py, which installs it
|
||||
# on demand the first time the user enables Feishu (fixes
|
||||
# zhayujie/CowAgent#2987: "客户端没有飞书通道"). pip/setuptools/wheel are
|
||||
# bundled (small) so that on-demand install can run inside the frozen app.
|
||||
# `excludes` note below and channel/feishu/lark_install.py, which downloads a
|
||||
# trimmed bundle the first time the user enables Feishu (fixes
|
||||
# zhayujie/CowAgent#2987: "客户端没有飞书通道").
|
||||
hiddenimports = [
|
||||
# channels (dynamic import in channel/channel_factory.py)
|
||||
'channel.web.web_channel',
|
||||
@@ -48,12 +47,6 @@ hiddenimports = [
|
||||
'channel.telegram.telegram_channel',
|
||||
'channel.slack.slack_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
|
||||
@@ -162,17 +155,17 @@ datas += collect_data_files('playwright', include_py_files=True)
|
||||
# --- Excludes -------------------------------------------------------------
|
||||
# Keep the bundle lean: drop tests/docs and dev-only packages.
|
||||
#
|
||||
# Feishu's lark_oapi (~116MB + deps) is intentionally EXCLUDED here. The desktop
|
||||
# client does not bundle it; instead, the first time a user enables the Feishu
|
||||
# channel, channel/feishu/lark_install.py pip-installs lark_oapi (and its deps)
|
||||
# into a writable per-user dir (~/.cow/feishu_vendor) and adds it to sys.path.
|
||||
# This keeps the base download ~116MB smaller while Feishu still works out of
|
||||
# the box after a one-time, on-demand download (fixes zhayujie/CowAgent#2987).
|
||||
# pip/setuptools/wheel are NOT excluded (see hiddenimports) so that on-demand
|
||||
# install can run inside the frozen app.
|
||||
# Feishu's lark_oapi (~122MB unpacked) is intentionally EXCLUDED here. The first
|
||||
# time a user enables the Feishu channel, channel/feishu/lark_install.py
|
||||
# downloads a trimmed, pure-Python bundle (~1MB, built by
|
||||
# desktop/build/build-feishu-vendor.py) into ~/.cow/feishu_vendor and adds it to
|
||||
# sys.path. This keeps the base download small while Feishu still works out of
|
||||
# the box after a one-time fetch (fixes zhayujie/CowAgent#2987).
|
||||
excludes = [
|
||||
'lark_oapi', # Feishu SDK — installed on demand, NOT bundled (see above)
|
||||
'lark_oapi', # Feishu SDK — fetched on demand, NOT bundled (see above)
|
||||
'tests',
|
||||
'pip',
|
||||
'wheel',
|
||||
'pytest',
|
||||
# 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
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
# (which Electron talks to) plus the agent core; the remaining IM channels are
|
||||
# cheap (~27MB total) so we keep them.
|
||||
#
|
||||
# Feishu's `lark-oapi` (~116MB + deps) is NOT listed here on purpose: the desktop
|
||||
# client installs it on demand the first time the user enables the Feishu channel
|
||||
# (channel/feishu/lark_install.py), keeping the base download ~116MB smaller.
|
||||
# pip/setuptools/wheel are required by that on-demand installer and must be
|
||||
# present in the build/run environment (they ship inside the frozen app too — see
|
||||
# the spec's hiddenimports), so they stay available.
|
||||
# Feishu's `lark-oapi` (~122MB unpacked) is NOT listed here on purpose: the
|
||||
# desktop client downloads a trimmed ~1MB bundle the first time the user enables
|
||||
# the Feishu channel (channel/feishu/lark_install.py), keeping the base download
|
||||
# small. That bundle relies on `requests` and `pycryptodome` below rather than
|
||||
# shipping its own copies.
|
||||
|
||||
# ---- core ----
|
||||
numpy>=1.21
|
||||
@@ -53,7 +52,7 @@ python-pptx
|
||||
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||
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). ----
|
||||
wechatpy
|
||||
pycryptodome
|
||||
@@ -62,7 +61,3 @@ websocket-client>=1.4.0
|
||||
python-telegram-bot
|
||||
slack_bolt
|
||||
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 sys
|
||||
from unittest import mock
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
def clean_vendor(monkeypatch, tmp_path):
|
||||
"""Point the vendor dir at a temp location and drop any lark_oapi from path."""
|
||||
def offline(monkeypatch, tmp_path):
|
||||
"""Isolate the vendor dir and pretend lark_oapi is not installed."""
|
||||
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("COW_DESKTOP", raising=False)
|
||||
# Ensure lark_oapi is not importable in this test process.
|
||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
||||
# Avoid real subprocess / real sys.path pollution.
|
||||
monkeypatch.setattr(subprocess_run := lark_install.subprocess, "run", mock.MagicMock())
|
||||
yield tmp_path
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_is_available_true_when_spec_found(monkeypatch):
|
||||
def test_is_available_reflects_import_machinery(monkeypatch):
|
||||
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 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):
|
||||
monkeypatch.setattr(lark_install, "is_available", lambda: True)
|
||||
# Should not attempt any install.
|
||||
with mock.patch.object(lark_install.subprocess, "run") as run:
|
||||
lark_install.ensure(allow_install=True)
|
||||
run.assert_not_called()
|
||||
called = []
|
||||
monkeypatch.setattr(lark_install, "_provision", lambda t: called.append(t))
|
||||
lark_install.ensure(allow_install=True)
|
||||
assert not called, "should not fetch anything when the SDK is importable"
|
||||
|
||||
|
||||
def test_ensure_non_desktop_raises(monkeypatch):
|
||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
||||
monkeypatch.delenv("COW_DESKTOP", raising=False)
|
||||
def test_ensure_non_desktop_raises(offline):
|
||||
with pytest.raises(ImportError):
|
||||
lark_install.ensure(allow_install=True)
|
||||
|
||||
|
||||
def test_ensure_allow_install_false_raises(monkeypatch):
|
||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
||||
def test_ensure_allow_install_false_raises(offline, monkeypatch):
|
||||
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||
with pytest.raises(ImportError):
|
||||
lark_install.ensure(allow_install=False)
|
||||
|
||||
|
||||
def test_ensure_desktop_installs_on_demand(monkeypatch, tmp_path):
|
||||
# Simulate: not available before, available after install.
|
||||
state = {"installed": False}
|
||||
def test_ensure_reuses_previously_unpacked_bundle(offline, monkeypatch):
|
||||
"""An existing vendor dir is activated without any download."""
|
||||
os.makedirs(os.path.join(lark_install.vendor_dir(), "lark_oapi"))
|
||||
|
||||
def fake_available():
|
||||
return state["installed"]
|
||||
def unexpected(target):
|
||||
raise AssertionError("should not have downloaded anything")
|
||||
|
||||
monkeypatch.setattr(lark_install, "is_available", fake_available)
|
||||
monkeypatch.setenv("COW_DESKTOP", "1")
|
||||
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.
|
||||
monkeypatch.setattr(lark_install, "_provision", unexpected)
|
||||
monkeypatch.setattr(lark_install, "_activate", _unpacked)
|
||||
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):
|
||||
monkeypatch.setattr(lark_install, "is_available", lambda: False)
|
||||
def test_ensure_downloads_and_unpacks(offline, monkeypatch):
|
||||
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):
|
||||
raise RuntimeError("no network")
|
||||
lark_install.ensure(allow_install=True)
|
||||
|
||||
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):
|
||||
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