mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
177 lines
6.6 KiB
Python
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())
|