deer-flow/scripts/check_extension.py
Wenchao An 0b78051840
feat(extensions): add example guide and packaging checks (#6755)
* feat(extensions): add example guide and packaging checks

* fix(extensions): align packaging checks with installer

Reject Windows drive-qualified wheel paths before extraction and reuse the installer entry-point validator. Cover unsafe paths, dotted attributes, and valid dotted modules in regression tests.

* fix(extensions): skip unused wheel extraction

Only extract wheels when browser manifests need validation, with a backend-only regression test. Link the three remaining example READMEs in the developer guide.

* docs(frontend): trim guidance intro below size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-12 22:51:20 +08:00

177 lines
6.6 KiB
Python

"""Build and inspect a trusted extension wheel without installing its code.
Run from the checkout root with the backend environment:
uv run --project backend --no-sync python scripts/check_extension.py PACKAGE
"""
from __future__ import annotations
import argparse
import ast
import configparser
import subprocess
import sys
import tempfile
from collections.abc import Sequence
from email.parser import Parser
from pathlib import Path, PurePosixPath, PureWindowsPath
from zipfile import BadZipFile, ZipFile
from deerflow_extension_api import API_VERSION, BrowserAssets
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from deerflow.extensions.browser_assets import load_browser_assets
from deerflow.extensions.manager import _validate_entry_point
def check_wheel(source: Path, wheel: Path, *, manifests: Sequence[str] = ()) -> None:
with (
ZipFile(wheel) as archive,
tempfile.TemporaryDirectory(prefix="deerflow-extension-wheel-") as temporary,
):
names = archive.namelist()
if len(names) != len(set(names)):
raise ValueError("Duplicate wheel paths")
for name in names:
path = PurePosixPath(name)
if (
path.is_absolute()
or PureWindowsPath(name).drive
or ".." in path.parts
or "\\" in name
):
raise ValueError("Invalid wheel path")
metadata_paths = [
name for name in names if name.endswith(".dist-info/METADATA")
]
if len(metadata_paths) != 1:
raise ValueError("Expected one wheel distribution")
metadata = Parser().parsestr(archive.read(metadata_paths[0]).decode("utf-8"))
requirements = [
Requirement(value) for value in metadata.get_all("Requires-Dist", [])
]
api = [
requirement
for requirement in requirements
if canonicalize_name(requirement.name) == "deerflow-extension-api"
and (
requirement.marker is None or requirement.marker.evaluate({"extra": ""})
)
]
if not api or any(
API_VERSION not in requirement.specifier for requirement in api
):
raise ValueError(
f"Declare a deerflow-extension-api dependency compatible with this checkout ({API_VERSION})"
)
entry_path = str(PurePosixPath(metadata_paths[0]).parent / "entry_points.txt")
entries = configparser.ConfigParser(interpolation=None)
entries.optionxform = str
if entry_path in names:
entries.read_string(archive.read(entry_path).decode("utf-8"))
if (
not entries.has_section("deerflow.extensions")
or len(entries["deerflow.extensions"]) != 1
):
raise ValueError("Declare exactly one deerflow.extensions entry point")
entry_name, target = next(iter(entries["deerflow.extensions"].items()))
_validate_entry_point(entry_name, target)
module, _ = target.rsplit(":", 1)
module_path = module.replace(".", "/")
if not any(
name in names
for name in (f"{module_path}.py", f"{module_path}/__init__.py")
):
raise ValueError("Extension entry module is not packaged")
for name in names:
if name.endswith(".py"):
ast.parse(archive.read(name), filename=name)
# Existing examples use flat or src-layout packages and ui_manifest.json.
# Custom manifest names are explicit rather than guessing from plugin code.
manifest_paths = set(manifests)
top_module = module.split(".")[0]
for import_root in (source, source / "src"):
package = import_root / top_module
if package.is_dir():
manifest_paths.update(
path.relative_to(import_root).as_posix()
for path in package.rglob("ui_manifest.json")
)
elif (
package.with_suffix(".py").is_file()
and (import_root / "ui_manifest.json").is_file()
):
manifest_paths.add("ui_manifest.json")
manifest_paths.update(
name for name in names if PurePosixPath(name).name == "ui_manifest.json"
)
for manifest in manifest_paths:
if manifest not in names:
raise ValueError(f"Browser manifest is not packaged: {manifest}")
if manifest_paths:
archive.extractall(temporary)
for manifest in sorted(manifest_paths):
path = Path(temporary) / manifest
load_browser_assets(BrowserAssets("check", path.parent, manifest=path.name))
print(f"Entry point: {entry_name} = {target}")
print(f"API dependency: compatible with {API_VERSION}")
print(f"Browser manifests: {len(manifest_paths)} checked")
def main() -> int:
parser = argparse.ArgumentParser(
description="Build and check a trusted Python extension package using the current DeerFlow contract."
)
parser.add_argument(
"package", type=Path, help="directory containing pyproject.toml"
)
parser.add_argument(
"--manifest",
action="append",
default=[],
help="custom browser manifest path inside the wheel (repeatable)",
)
args = parser.parse_args()
try:
source = args.package.resolve()
if not (source / "pyproject.toml").is_file():
raise ValueError("Package directory must contain pyproject.toml")
with tempfile.TemporaryDirectory(
prefix="deerflow-extension-check-"
) as temporary:
result = subprocess.run(
["uv", "build", str(source), "--wheel", "--out-dir", temporary],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=120,
check=False,
)
if result.returncode:
raise ValueError(f"Wheel build failed:\n{result.stderr}")
wheels = list(Path(temporary).glob("*.whl"))
if len(wheels) != 1:
raise ValueError("Build must produce one wheel")
check_wheel(source, wheels[0], manifests=args.manifest)
print(f"Packaging checks passed: {wheels[0].name}")
return 0
except (
OSError,
ValueError,
SyntaxError,
BadZipFile,
configparser.Error,
subprocess.TimeoutExpired,
) as exc:
print(f"Extension check failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())