mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat(extensions): add JSON output for configured extension inventory (#7351)
Co-authored-by: BaiHeng Peng <191328900+Aldenshile@users.noreply.github.com>
This commit is contained in:
parent
afc7014b48
commit
cc8a947c7d
16
README.md
16
README.md
@ -2064,6 +2064,22 @@ Git URLs are rejected because the stock Docker builder does not forward host SSH
|
||||
credentials. Installing from a loopback URL is allowed for local tooling but warns, because
|
||||
`127.0.0.1` recorded in the lock is a different machine inside the Docker builder.
|
||||
|
||||
For scripts, `deerflow extensions list --json` returns a JSON array in configuration
|
||||
order, with `name`, `package`, `entry_point`, `enabled`, and `required` fields. An
|
||||
unspecified package is `"-"`; an empty inventory is `[]`. Private plugin configuration
|
||||
and source metadata are excluded. The default `list` output remains a table.
|
||||
For example, from `backend/`, check that `demo` is configured as enabled and required:
|
||||
|
||||
```bash
|
||||
inventory=$(uv run --frozen --no-group extensions deerflow extensions list --json) || exit 1
|
||||
printf '%s\n' "$inventory" |
|
||||
jq -e 'any(.[]; .name == "demo" and .enabled == true and .required == true)'
|
||||
```
|
||||
|
||||
This reports configured state without executing plugins or changing files; it does
|
||||
not verify package installation or successful Gateway loading. Configuration errors
|
||||
exit non-zero with diagnostics on stderr and no inventory on stdout.
|
||||
|
||||
A managed package declares exactly one standard PEP 621 entry point:
|
||||
|
||||
```toml
|
||||
|
||||
@ -22,12 +22,19 @@ Packaged extensions use one PEP 621 entry point in the
|
||||
`deerflow.extensions` group, for example
|
||||
`example = "deerflow_extension_example:install"`. The operator CLI is dispatched from
|
||||
the existing `deerflow` console script to `extensions/cli.py` and exposes only these
|
||||
surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list`, `enable NAME`,
|
||||
surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list [--json]`, `enable NAME`,
|
||||
`disable NAME`, and `remove NAME`. `NAME` resolves against the entry-point name, distribution name, or
|
||||
`module:install` value. The root `make extension-*` targets are convenience wrappers;
|
||||
because they execute from `backend/`, documentation should use absolute local source
|
||||
paths with `SOURCE=` unless backend-relative behavior is intentional.
|
||||
|
||||
`list --json` serializes the existing `list_configured()` inventory as an ordered
|
||||
array with only `name`, `package`, `entry_point`, `enabled`, and `required`. Keep
|
||||
the manager's `"-"` package fallback and schema defaults/coercion. Validate the
|
||||
whole inventory before writing stdout; failures use the existing stderr/non-zero
|
||||
path. Listing must not resolve entry points, mutate files, or expose private config
|
||||
or source metadata. CLI coverage lives in `tests/test_extension_list_json.py`.
|
||||
|
||||
`ExtensionManager` owns the package/config transaction. Install runs a controlled
|
||||
`uv add --project <backend> --group extensions --no-workspace --no-sync -- <source>`, updates the dedicated
|
||||
`[dependency-groups].extensions` list and `uv.lock`, discovers exactly one packaging entry
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@ -51,7 +52,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
enable = commands.add_parser("enable", help="enable an installed extension")
|
||||
enable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
|
||||
enable.add_argument("name", help="extension name, distribution, or module:install entry point")
|
||||
commands.add_parser("list", help="list configured extensions")
|
||||
list_command = commands.add_parser("list", help="list configured extensions")
|
||||
list_command.add_argument("--json", action="store_true", help="output configured extension inventory as JSON")
|
||||
remove = commands.add_parser("remove", help="uninstall an extension and remove its config entry")
|
||||
remove.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
|
||||
remove.add_argument("name", help="extension name, distribution, or module:install entry point")
|
||||
@ -104,6 +106,19 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return 0
|
||||
if args.command == "list":
|
||||
configured = manager.list_configured()
|
||||
if args.json:
|
||||
inventory = [
|
||||
{
|
||||
"name": extension.name,
|
||||
"package": extension.distribution,
|
||||
"entry_point": extension.use,
|
||||
"enabled": extension.enabled,
|
||||
"required": extension.required,
|
||||
}
|
||||
for extension in configured
|
||||
]
|
||||
print(json.dumps(inventory, indent=2))
|
||||
return 0
|
||||
print("NAME\tSTATE\tPACKAGE\tENTRY POINT")
|
||||
for extension in configured:
|
||||
state = "enabled" if extension.enabled else "disabled"
|
||||
|
||||
126
backend/tests/test_extension_list_json.py
Normal file
126
backend/tests/test_extension_list_json.py
Normal file
@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from deerflow.tui.cli import main as deerflow_main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkout(tmp_path: Path, monkeypatch) -> Path:
|
||||
root = tmp_path / "deer-flow"
|
||||
(root / "backend").mkdir(parents=True)
|
||||
(root / "backend" / "pyproject.toml").write_text('[project]\nname = "inventory-test"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(root))
|
||||
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
|
||||
return root
|
||||
|
||||
|
||||
def test_json_inventory_preserves_order_defaults_and_only_public_fields(checkout: Path, capsys) -> None:
|
||||
plugins = [
|
||||
{
|
||||
"name": '演示 "extension"\nsecond line',
|
||||
"package": "deerflow-extension-demo",
|
||||
"use": "not_installed_demo:install",
|
||||
"enabled": "false",
|
||||
"required": "true",
|
||||
"config": {"token": "private-test-value", "nested": {"path": "/private/source"}},
|
||||
},
|
||||
{"use": "not_installed_other:install"},
|
||||
]
|
||||
(checkout / "config.yaml").write_text(yaml.safe_dump({"plugins": plugins}), encoding="utf-8")
|
||||
|
||||
assert deerflow_main(["extensions", "list", "--json"]) == 0
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
inventory = json.loads(captured.out)
|
||||
assert inventory == [
|
||||
{
|
||||
"name": plugins[0]["name"],
|
||||
"package": "deerflow-extension-demo",
|
||||
"entry_point": "not_installed_demo:install",
|
||||
"enabled": False,
|
||||
"required": True,
|
||||
},
|
||||
{"name": "not_installed_other:install", "package": "-", "entry_point": "not_installed_other:install", "enabled": True, "required": False},
|
||||
]
|
||||
for extension in inventory:
|
||||
assert isinstance(extension["enabled"], bool)
|
||||
assert isinstance(extension["required"], bool)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", ["plugins: []\n", "plugins:\n", "config_version: 1\n"])
|
||||
def test_json_empty_inventory(checkout: Path, capsys, config: str) -> None:
|
||||
(checkout / "config.yaml").write_text(config, encoding="utf-8")
|
||||
|
||||
assert deerflow_main(["extensions", "list", "--json"]) == 0
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out) == []
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [[], ["--json"]], ids=["table", "json"])
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
None,
|
||||
"plugins: [\n",
|
||||
"- not-a-mapping\n",
|
||||
"plugins: {}\n",
|
||||
"plugins: []\nplugins: []\n",
|
||||
"plugins:\n - use: valid:install\n - name: missing-use\n",
|
||||
],
|
||||
ids=["missing-file", "invalid-yaml", "invalid-root", "invalid-plugins", "duplicate-key", "invalid-later-entry"],
|
||||
)
|
||||
def test_list_config_errors_have_no_partial_stdout(checkout: Path, capsys, args: list[str], config: str | None) -> None:
|
||||
if config is not None:
|
||||
(checkout / "config.yaml").write_text(config, encoding="utf-8")
|
||||
|
||||
assert deerflow_main(["extensions", "list", *args]) == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "extension command failed:" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
|
||||
|
||||
def test_default_table_output_is_unchanged(checkout: Path, capsys) -> None:
|
||||
(checkout / "config.yaml").write_text(
|
||||
"plugins:\n - name: demo\n package: demo-package\n use: demo:install\n required: true\n - use: other:install\n enabled: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert deerflow_main(["extensions", "list"]) == 0
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "NAME\tSTATE\tPACKAGE\tENTRY POINT\ndemo\tenabled\tdemo-package\tdemo:install\nother:install\tdisabled\t-\tother:install\n"
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_console_json_uses_selected_config_without_executing_plugins_or_writing_files(checkout: Path) -> None:
|
||||
(checkout / "config.yaml").write_text("plugins: []\n", encoding="utf-8")
|
||||
selected = checkout / "deployment.yaml"
|
||||
selected.write_text("plugins:\n - use: must_not_import:install\n required: true\n", encoding="utf-8")
|
||||
# Importing this configured entry point would fail the command and leave evidence.
|
||||
(checkout / "must_not_import.py").write_text(
|
||||
"from pathlib import Path\nPath('plugin-executed').touch()\nraise RuntimeError('plugin must not run')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
before = {path.relative_to(checkout): path.read_bytes() for path in checkout.rglob("*") if path.is_file()}
|
||||
env = {**os.environ, "DEER_FLOW_CONFIG_PATH": str(selected), "PYTHONPATH": str(checkout)}
|
||||
console = Path(sys.executable).parent / ("deerflow.exe" if os.name == "nt" else "deerflow")
|
||||
|
||||
result = subprocess.run([str(console), "extensions", "list", "--json"], cwd=checkout, env=env, capture_output=True, text=True, encoding="utf-8", timeout=30)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stderr == ""
|
||||
assert json.loads(result.stdout) == [{"name": "must_not_import:install", "package": "-", "entry_point": "must_not_import:install", "enabled": True, "required": True}]
|
||||
assert {path.relative_to(checkout): path.read_bytes() for path in checkout.rglob("*") if path.is_file()} == before
|
||||
Loading…
x
Reference in New Issue
Block a user