feat(extensions): add in-place upgrade that keeps private config (#5347)

* feat(extensions): add in-place upgrade that keeps private config

Replace a managed local snapshot or re-pin an already-installed
requirement without going through remove, which dropped plugins[].config.

* fix(extensions): keep snapshot, enabled, and git re-pin on upgrade

Rollback keys off staging_root so a failed snapshot rename cannot
rmtree the live tree. Upgrade preserves plugins[].enabled. Re-pin
identification uses tool.uv.sources so git upgrades adopt the
existing plugin record instead of failing closed after uv already
switched the revision.

* test(extensions): cover requirement re-pin identification on upgrade

Re-pinning deerflow-extension-demo==2.0.0 to ==3.0.0 leaves added_names
empty, so identification must take the added_specs fallback. Assert
private config/required/enabled survive and the lock records 3.0.0.

* fix(extensions): reject upgrade of an uninstalled git source

Bare git+ URLs are not named Requirements, so the pre-uv-add installed
check was skipped and upgrade acted as install. Resolve them against
[tool.uv.sources] in the extensions group before uv add.
This commit is contained in:
wutongyuonce 2026-09-12 15:59:41 +08:00 committed by GitHub
parent 3e536944b7
commit f17ca3777a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 608 additions and 41 deletions

View File

@ -77,7 +77,7 @@ Third-party extensions are loaded from a top-level `plugins:` list in `config.ya
kept out of the API-writable `extensions_config.json`). Packaged extensions can contribute kept out of the API-writable `extensions_config.json`). Packaged extensions can contribute
middleware, task lifecycle, system-model observers, Gateway services, and FastAPI HTTP middleware, task lifecycle, system-model observers, Gateway services, and FastAPI HTTP
routers; the [reference extension](examples/deerflow-extension-example/) demonstrates all routers; the [reference extension](examples/deerflow-extension-example/) demonstrates all
five. Manage them with `deerflow extensions install/list/enable/disable/remove` or the root five. Manage them with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root
`make extension-*` wrappers. Every mutation requires a Gateway restart, and both build `make extension-*` wrappers. Every mutation requires a Gateway restart, and both build
hooks and extension code execute with Gateway privileges, so only trusted operator sources hooks and extension code execute with Gateway privileges, so only trusted operator sources
belong in this path. The manager transaction, accepted source forms, lock discipline, and belong in this path. The manager transaction, accepted source forms, lock discipline, and
@ -125,6 +125,7 @@ make config # Generate local config files from the examples
make check # Check that required tools are installed make check # Check that required tools are installed
make install # Install all dependencies (frontend + backend + pre-commit hooks) make install # Install all dependencies (frontend + backend + pre-commit hooks)
make extension-install SOURCE=... # Install and enable a trusted Python extension make extension-install SOURCE=... # Install and enable a trusted Python extension
make extension-upgrade SOURCE=... # Replace an installed extension and keep its config
make extension-list # List configured Python extensions make extension-list # List configured Python extensions
make extension-enable NAME=... # Enable an installed extension (restart required) make extension-enable NAME=... # Enable an installed extension (restart required)
make extension-disable NAME=... # Disable without uninstalling (restart required) make extension-disable NAME=... # Disable without uninstalling (restart required)

View File

@ -1,6 +1,6 @@
# DeerFlow - Unified Development Environment # DeerFlow - Unified Development Environment
.PHONY: help config config-upgrade check check-agent-guidance install extension-install extension-list extension-enable extension-disable extension-remove setup doctor support-bundle detect-thread-boundaries detect-blocking-io dev dev-daemon start start-daemon nginx stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway docker-logs-redis setup-sandbox .PHONY: help config config-upgrade check check-agent-guidance install extension-install extension-upgrade extension-list extension-enable extension-disable extension-remove setup doctor support-bundle detect-thread-boundaries detect-blocking-io dev dev-daemon start start-daemon nginx stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway docker-logs-redis setup-sandbox
BASH ?= bash BASH ?= bash
BACKEND_UV_RUN = cd backend && uv run BACKEND_UV_RUN = cd backend && uv run
@ -34,6 +34,7 @@ help:
@echo " make detect-blocking-io - Inventory blocking IO that may block the backend event loop" @echo " make detect-blocking-io - Inventory blocking IO that may block the backend event loop"
@echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)" @echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)"
@echo " make extension-install SOURCE=... - Install and enable a trusted Python extension" @echo " make extension-install SOURCE=... - Install and enable a trusted Python extension"
@echo " make extension-upgrade SOURCE=... - Replace an installed extension and keep its config"
@echo " make extension-list - List configured Python extensions" @echo " make extension-list - List configured Python extensions"
@echo " make extension-enable NAME=... - Enable an installed extension" @echo " make extension-enable NAME=... - Enable an installed extension"
@echo " make extension-disable NAME=... - Disable an extension without uninstalling it" @echo " make extension-disable NAME=... - Disable an extension without uninstalling it"
@ -113,6 +114,11 @@ extension-install:
$(if $(and $(filter command line,$(origin SOURCE)),$(strip $(value SOURCE))),,$(error usage: make extension-install SOURCE=<package|git-url|dir>)) $(if $(and $(filter command line,$(origin SOURCE)),$(strip $(value SOURCE))),,$(error usage: make extension-install SOURCE=<package|git-url|dir>))
@cd backend && uv run --frozen --no-group extensions deerflow extensions install --source-env __deerflow_extension_source__ @cd backend && uv run --frozen --no-group extensions deerflow extensions install --source-env __deerflow_extension_source__
extension-upgrade: export DEER_FLOW_EXTENSION_SOURCE := $(value SOURCE)
extension-upgrade:
$(if $(and $(filter command line,$(origin SOURCE)),$(strip $(value SOURCE))),,$(error usage: make extension-upgrade SOURCE=<package|git-url|dir>))
@cd backend && uv run --frozen --no-group extensions deerflow extensions upgrade --source-env __deerflow_extension_source__
extension-list: extension-list:
@cd backend && uv run --frozen --no-group extensions deerflow extensions list @cd backend && uv run --frozen --no-group extensions deerflow extensions list

View File

@ -1047,6 +1047,7 @@ make extension-install \
make extension-install SOURCE="$PWD/examples/deerflow-extension-example" make extension-install SOURCE="$PWD/examples/deerflow-extension-example"
make extension-list make extension-list
make extension-upgrade SOURCE="$PWD/examples/deerflow-extension-example"
make extension-disable NAME=acme make extension-disable NAME=acme
make extension-enable NAME=acme make extension-enable NAME=acme
make extension-remove NAME=acme make extension-remove NAME=acme
@ -1058,7 +1059,7 @@ source, automation can acknowledge that boundary explicitly with
`cd backend && uv run --frozen --no-group extensions deerflow extensions install <source> --yes`. `cd backend && uv run --frozen --no-group extensions deerflow extensions install <source> --yes`.
The manager requires uv 0.8.0 or newer; the provided Docker images pin uv 0.11.1. The manager requires uv 0.8.0 or newer; the provided Docker images pin uv 0.11.1.
The other direct The other direct
commands are `deerflow extensions list`, `enable NAME`, `disable NAME`, and `remove NAME`; commands are `deerflow extensions upgrade SOURCE`, `list`, `enable NAME`, `disable NAME`, and `remove NAME`;
`NAME` may be the extension name, Python distribution, or `module:install` value. Do not `NAME` may be the extension name, Python distribution, or `module:install` value. Do not
put credentials in a source URL — a URL carrying embedded userinfo or a credential-looking put credentials in a source URL — a URL carrying embedded userinfo or a credential-looking
query parameter is rejected before uv runs. Remote Git sources must use public HTTPS; SSH query parameter is rejected before uv runs. Remote Git sources must use public HTTPS; SSH

View File

@ -155,6 +155,7 @@ uv run pytest tests/test_bench_concurrency.py tests/test_bench_worker.py -q
make check # Check system requirements make check # Check system requirements
make install # Install all dependencies (frontend + backend) make install # Install all dependencies (frontend + backend)
make extension-install SOURCE=... # Install and enable a trusted Python extension make extension-install SOURCE=... # Install and enable a trusted Python extension
make extension-upgrade SOURCE=... # Replace an installed extension and keep its config
make extension-list # List configured Python extensions make extension-list # List configured Python extensions
make extension-enable NAME=... # Enable an installed extension make extension-enable NAME=... # Enable an installed extension
make extension-disable NAME=... # Disable an extension without uninstalling it make extension-disable NAME=... # Disable an extension without uninstalling it

View File

@ -11,8 +11,8 @@ Packaged extensions use one PEP 621 entry point in the
`deerflow.extensions` group, for example `deerflow.extensions` group, for example
`example = "deerflow_extension_example:install"`. The operator CLI is dispatched from `example = "deerflow_extension_example:install"`. The operator CLI is dispatched from
the existing `deerflow` console script to `extensions/cli.py` and exposes only these the existing `deerflow` console script to `extensions/cli.py` and exposes only these
surfaces: `install SOURCE [--yes]`, `list`, `enable NAME`, `disable NAME`, and surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list`, `enable NAME`,
`remove NAME`. `NAME` resolves against the entry-point name, distribution name, or `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; `module:install` value. The root `make extension-*` targets are convenience wrappers;
because they execute from `backend/`, documentation should use absolute local source because they execute from `backend/`, documentation should use absolute local source
paths with `SOURCE=` unless backend-relative behavior is intentional. paths with `SOURCE=` unless backend-relative behavior is intentional.
@ -53,11 +53,15 @@ and `UV_INSECURE_HOST`, which would remove the TLS validation the HTTPS-only sou
depends on; index, proxy, cache, and credential-provider settings remain available. depends on; index, proxy, cache, and credential-provider settings remain available.
The `--no-workspace` boundary requires uv 0.8.0 or newer. The stock Docker paths pin uv The `--no-workspace` boundary requires uv 0.8.0 or newer. The stock Docker paths pin uv
0.11.1, and the manager fails before mutation when the host uv is older. 0.11.1, and the manager fails before mutation when the host uv is older.
All install/remove/enable/disable mutations for a checkout hold the cross-process All install/upgrade/remove/enable/disable mutations for a checkout hold the cross-process
`.deer-flow/extension-manager.lock`; remove deactivates config before changing the package `.deer-flow/extension-manager.lock`; remove deactivates config before changing the package
declaration, and rollback preserves a concurrent external config edit instead of replacing declaration, and rollback preserves a concurrent external config edit instead of replacing
it. The MVP has no in-place upgrade: operators retain private config, remove the old it. Upgrade replaces a managed local snapshot (or re-pins a package requirement that is already
package, install the new source pin, and restore that config. in the `extensions` group) and adopts the existing `plugins:` record so private `config`,
`required`, and `enabled` stay put. It fails closed if that local snapshot, requirement, or Git source is not
already installed; a plain `install` still refuses an already-snapshotted local directory.
Failed upgrades restore the previous snapshot even when a concurrent dependency-file edit
blocks lock/pyproject rollback, then leave that operator edit in place.
Local-directory installs are snapshots, not editable links. The manager validates the Local-directory installs are snapshots, not editable links. The manager validates the
source, derives the destination from the normalized distribution name, and copies it to source, derives the destination from the normalized distribution name, and copies it to
@ -126,9 +130,9 @@ newer uv can bump `uv.lock`'s `revision` (or make `uv lock --check` disagree wit
generated elsewhere) while CI stays green, and the pinned uv in the production image then generated elsewhere) while CI stays green, and the pinned uv in the production image then
fails on the committed lock. `backend/tests/test_ci_uv_version_pin.py` keeps the four fails on the committed lock. `backend/tests/test_ci_uv_version_pin.py` keeps the four
locations in step, which makes a uv upgrade one deliberate, reviewable change. locations in step, which makes a uv upgrade one deliberate, reviewable change.
Rebuild the Gateway image after changing the managed set. Every install, enable, disable, Rebuild the Gateway image after changing the managed set. Every install, upgrade, enable,
remove, or config mutation also requires a Gateway restart because plugin loading is disable, remove, or config mutation also requires a Gateway restart because plugin loading
startup-only. is startup-only.
The root management wrappers bootstrap the checkout environment without the extension group The root management wrappers bootstrap the checkout environment without the extension group
via `uv run --frozen --no-group extensions`, so a broken or disappeared extension source cannot via `uv run --frozen --no-group extensions`, so a broken or disappeared extension source cannot
trigger project validation before the operator can list, disable, or remove it, while a trigger project validation before the operator can list, disable, or remove it, while a

View File

@ -34,6 +34,17 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="abort Gateway startup when this extension fails to load (default: report and skip)", help="abort Gateway startup when this extension fails to load (default: report and skip)",
) )
upgrade = commands.add_parser(
"upgrade",
help="replace an installed extension source and keep its private config",
)
upgrade.add_argument("--source-env", action="store_true", help=argparse.SUPPRESS)
upgrade.add_argument("source", help="local directory, Python package requirement, or Git URL")
upgrade.add_argument(
"--yes",
action="store_true",
help="acknowledge that upgrading an extension executes trusted third-party code",
)
disable = commands.add_parser("disable", help="disable an extension without uninstalling it") disable = commands.add_parser("disable", help="disable an extension without uninstalling it")
disable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS) disable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS)
disable.add_argument("name", help="extension name, distribution, or module:install entry point") disable.add_argument("name", help="extension name, distribution, or module:install entry point")
@ -68,6 +79,21 @@ def main(argv: Sequence[str] | None = None) -> int:
installed = manager.install(source, yes=trusted, required=args.required) installed = manager.install(source, yes=trusted, required=args.required)
print(f"Installed and enabled {installed.name} ({installed.distribution}). Restart DeerFlow to load it.") print(f"Installed and enabled {installed.name} ({installed.distribution}). Restart DeerFlow to load it.")
return 0 return 0
if args.command == "upgrade":
source = _source_argument(args)
trusted = args.yes
if not trusted:
print("Warning: a Python extension executes code with Gateway privileges.")
try:
trusted = input("Upgrade this trusted source? [y/N] ").strip().lower() in {"y", "yes"}
except EOFError:
trusted = False
if not trusted:
print("Extension upgrade cancelled.", file=sys.stderr)
return 2
installed = manager.upgrade(source, yes=trusted)
print(f"Upgraded {installed.name} ({installed.distribution}). Restart DeerFlow to load it.")
return 0
if args.command == "disable": if args.command == "disable":
name = manager.set_enabled(_name_argument(args), enabled=False) name = manager.set_enabled(_name_argument(args), enabled=False)
print(f"Disabled {name}. Restart DeerFlow to apply the change.") print(f"Disabled {name}. Restart DeerFlow to apply the change.")

View File

@ -131,14 +131,19 @@ class ExtensionManager:
selected_config = root_config if root_config.is_file() or not legacy_config.is_file() else legacy_config selected_config = root_config if root_config.is_file() or not legacy_config.is_file() else legacy_config
self.config_path = selected_config.resolve() self.config_path = selected_config.resolve()
def install(self, source: str, *, yes: bool = False, required: bool = False) -> InstalledExtension: def install(self, source: str, *, yes: bool = False, required: bool = False, replace: bool = False) -> InstalledExtension:
"""Install an extension source and enable its packaging entry point.""" """Install an extension source and enable its packaging entry point."""
with _manager_lock(self.project_root): with _manager_lock(self.project_root):
return self._install(source, yes=yes, required=required) return self._install(source, yes=yes, required=required, replace=replace)
def _install(self, source: str, *, yes: bool, required: bool) -> InstalledExtension: def upgrade(self, source: str, *, yes: bool = False) -> InstalledExtension:
"""Replace an installed extension source without dropping its private config or enabled state."""
return self.install(source, yes=yes, replace=True)
def _install(self, source: str, *, yes: bool, required: bool, replace: bool) -> InstalledExtension:
if not yes: if not yes:
raise PermissionError("installing an extension executes trusted third-party code; pass yes=True to continue") action = "upgrading" if replace else "installing"
raise PermissionError(f"{action} an extension executes trusted third-party code; pass yes=True to continue")
source_argument = Path(source).expanduser() source_argument = Path(source).expanduser()
if _is_link_like(source_argument): if _is_link_like(source_argument):
@ -156,29 +161,55 @@ class ExtensionManager:
managed_source = (managed_root / normalized_distribution).resolve() managed_source = (managed_root / normalized_distribution).resolve()
if not managed_source.is_relative_to(managed_root): if not managed_source.is_relative_to(managed_root):
raise ValueError(f"invalid extension distribution name: {distribution!r}") raise ValueError(f"invalid extension distribution name: {distribution!r}")
if managed_source.exists(): if managed_source.exists() and not replace:
raise FileExistsError(f"extension source is already installed: {managed_source}") raise FileExistsError(f"extension source is already installed: {managed_source}")
if replace and not managed_source.exists():
raise ValueError(f"extension source is not installed: {managed_source}; use install")
uv_source = str(managed_source.relative_to(self.backend_dir)) uv_source = str(managed_source.relative_to(self.backend_dir))
else: else:
if source_argument.exists(): if source_argument.exists():
raise ValueError("local extension sources must be directories so they can be snapshotted for deployment") raise ValueError("local extension sources must be directories so they can be snapshotted for deployment")
_validate_remote_source(source) _validate_remote_source(source)
if replace:
try:
remote_distribution = _normalize_distribution(Requirement(source).name)
except InvalidRequirement:
remote_distribution = None
if remote_distribution is not None:
if remote_distribution not in _extension_dependency_names(self.pyproject_path):
raise ValueError(f"extension {remote_distribution!r} is not installed; use install")
elif _installed_git_distribution(source, self.pyproject_path) is None:
# Bare git+ URLs are not named Requirements; resolve them
# against the already-installed extensions group instead.
raise ValueError("extension source is not installed; use install")
# uv add/sync execute the package's build backend. A config this manager # uv add/sync execute the package's build backend. A config this manager
# could never write to must fail before that code runs, not afterwards # could never write to must fail before that code runs, not afterwards
# through rollback. # through rollback.
self._read_plugins() self._read_plugins()
_require_supported_uv(self.backend_dir) _require_supported_uv(self.backend_dir)
dependencies_before = _extension_dependency_names(self.pyproject_path) specs_before = _extension_dependencies(self.pyproject_path)
sources_before = _uv_sources(self.pyproject_path)
dependency_snapshots = ( dependency_snapshots = (
_FileSnapshot.capture(self.pyproject_path), _FileSnapshot.capture(self.pyproject_path),
_FileSnapshot.capture(self.backend_dir / "uv.lock"), _FileSnapshot.capture(self.backend_dir / "uv.lock"),
) )
managed_dependency_contents: tuple[bytes | None, ...] | None = None managed_dependency_contents: tuple[bytes | None, ...] | None = None
uv_attempted = False uv_attempted = False
staging_root: Path | None = None
staged_source: Path | None = None
try: try:
if managed_source is not None: if managed_source is not None:
managed_source.parent.mkdir(parents=True, exist_ok=True) managed_source.parent.mkdir(parents=True, exist_ok=True)
if replace and managed_source.exists():
staging_root = Path(
tempfile.mkdtemp(
prefix=f".{managed_source.name}.upgrade-",
dir=managed_source.parent,
)
)
staged_source = staging_root / "source"
managed_source.rename(staged_source)
shutil.copytree( shutil.copytree(
source_path, source_path,
managed_source, managed_source,
@ -211,10 +242,12 @@ class ExtensionManager:
_validate_locked_local_sources(self.backend_dir / "uv.lock", self.backend_dir) _validate_locked_local_sources(self.backend_dir / "uv.lock", self.backend_dir)
_sync_environment(self.project_root, self.backend_dir, self.config_path) _sync_environment(self.project_root, self.backend_dir, self.config_path)
if metadata is None: if metadata is None:
added = _extension_dependency_names(self.pyproject_path) - dependencies_before distribution = _identify_uv_added_distribution(
if len(added) != 1: self.pyproject_path,
raise RuntimeError("could not identify the distribution added by uv") specs_before=specs_before,
distribution = next(iter(added)) sources_before=sources_before,
replace=replace,
)
name, use = _discover_installed_entry_point(self.backend_dir, distribution) name, use = _discover_installed_entry_point(self.backend_dir, distribution)
metadata = (distribution, name, use) metadata = (distribution, name, use)
else: else:
@ -230,8 +263,11 @@ class ExtensionManager:
"enabled": True, "enabled": True,
"required": required, "required": required,
"config": {}, "config": {},
} },
preserve_enabled=replace,
) )
if staging_root is not None:
shutil.rmtree(staging_root, ignore_errors=True)
except BaseException as operation_error: except BaseException as operation_error:
# _enable_plugin performs the only config mutation as the final, # _enable_plugin performs the only config mutation as the final,
# atomic step. A failure before it must not roll back an operator # atomic step. A failure before it must not roll back an operator
@ -245,12 +281,21 @@ class ExtensionManager:
strict=True, strict=True,
) )
) )
if staging_root is not None:
# Key restore off staging, not a flag set after rename: a failed
# rename must leave the live snapshot in place and only remove
# the empty .*.upgrade-* directory.
if staged_source is not None and staged_source.exists():
if managed_source is not None:
shutil.rmtree(managed_source, ignore_errors=True)
staged_source.rename(managed_source)
shutil.rmtree(staging_root, ignore_errors=True)
elif managed_source is not None and not dependency_recovery_conflict:
shutil.rmtree(managed_source, ignore_errors=True)
if dependency_recovery_conflict: if dependency_recovery_conflict:
raise RuntimeError("extension installation recovery preserved a concurrent dependency-file edit") from operation_error raise RuntimeError("extension installation recovery preserved a concurrent dependency-file edit") from operation_error
for snapshot in dependency_snapshots: for snapshot in dependency_snapshots:
snapshot.restore() snapshot.restore()
if managed_source is not None:
shutil.rmtree(managed_source, ignore_errors=True)
# The recovery sync itself may rewrite the dependency files, so the # The recovery sync itself may rewrite the dependency files, so the
# second restore has to run even when that sync fails. # second restore has to run even when that sync fails.
try: try:
@ -406,7 +451,7 @@ class ExtensionManager:
) )
return tuple(configured) return tuple(configured)
def _enable_plugin(self, plugin: dict[str, Any]) -> None: def _enable_plugin(self, plugin: dict[str, Any], *, preserve_enabled: bool = False) -> None:
original, plugins = self._read_plugins() original, plugins = self._read_plugins()
exact_use_matches = [item for item in plugins if isinstance(item, dict) and item.get("use") == plugin["use"]] exact_use_matches = [item for item in plugins if isinstance(item, dict) and item.get("use") == plugin["use"]]
identity_conflicts = [item for item in plugins if isinstance(item, dict) and item not in exact_use_matches and (item.get("name") == plugin["name"] or _same_distribution(item.get("package"), plugin["package"]))] identity_conflicts = [item for item in plugins if isinstance(item, dict) and item not in exact_use_matches and (item.get("name") == plugin["name"] or _same_distribution(item.get("package"), plugin["package"]))]
@ -420,7 +465,8 @@ class ExtensionManager:
existing["name"] = plugin["name"] existing["name"] = plugin["name"]
existing["package"] = plugin["package"] existing["package"] = plugin["package"]
existing["use"] = plugin["use"] existing["use"] = plugin["use"]
existing["enabled"] = True if not preserve_enabled:
existing["enabled"] = True
existing.setdefault("required", plugin["required"]) existing.setdefault("required", plugin["required"])
existing.setdefault("config", {}) existing.setdefault("config", {})
_write_plugins_block(self.config_path, original, plugins) _write_plugins_block(self.config_path, original, plugins)
@ -615,6 +661,22 @@ def _strip_git_prefix(reference: str) -> str:
return reference[4:] if reference.lower().startswith("git+") else reference return reference[4:] if reference.lower().startswith("git+") else reference
def _git_repository_identity(reference: str) -> tuple[str, int | None, str] | None:
"""Host, port, and path that identify a Git repo, ignoring ref and fragment."""
parsed = urllib.parse.urlsplit(_strip_git_prefix(reference.strip()))
if parsed.scheme.lower() not in {"http", "https"}:
return None
host = parsed.hostname
if host is None:
return None
path = parsed.path.rsplit("@", 1)[0].rstrip("/")
if path.endswith(".git"):
path = path[:-4]
if not path:
return None
return (host.lower(), parsed.port, path)
def _is_scp_like_reference(source: str) -> bool: def _is_scp_like_reference(source: str) -> bool:
# The bare shorthand is checked directly; a PEP 508 direct reference keeps # The bare shorthand is checked directly; a PEP 508 direct reference keeps
# it behind the requirement name, which packaging strips off the URL. # it behind the requirement name, which packaging strips off the URL.
@ -639,20 +701,83 @@ def _normalize_query_key(key: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "-", camel_case_split).strip("-").lower() return re.sub(r"[^A-Za-z0-9]+", "-", camel_case_split).strip("-").lower()
def _extension_dependency_names(pyproject: Path) -> set[str]: def _extension_dependencies(pyproject: Path) -> tuple[str, ...]:
with pyproject.open("rb") as stream: with pyproject.open("rb") as stream:
document = tomllib.load(stream) document = tomllib.load(stream)
dependencies = document.get("dependency-groups", {}).get("extensions", []) dependencies = document.get("dependency-groups", {}).get("extensions", [])
return tuple(dependency for dependency in dependencies if isinstance(dependency, str))
def _uv_sources(pyproject: Path) -> dict[str, Any]:
with pyproject.open("rb") as stream:
document = tomllib.load(stream)
sources = document.get("tool", {}).get("uv", {}).get("sources", {})
return sources if isinstance(sources, dict) else {}
def _distribution_name_from_spec(spec: str) -> str | None:
match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", spec)
if match is None:
return None
return _normalize_distribution(match.group(1))
def _identify_uv_added_distribution(
pyproject: Path,
*,
specs_before: tuple[str, ...],
sources_before: dict[str, Any],
replace: bool,
) -> str:
names_before: set[str] = set()
for spec in specs_before:
name = _distribution_name_from_spec(spec)
if name is not None:
names_before.add(name)
added_names = _extension_dependency_names(pyproject) - names_before
if len(added_names) == 1:
return next(iter(added_names))
added_specs = [spec for spec in _extension_dependencies(pyproject) if spec not in specs_before]
if len(added_specs) == 1:
name = _distribution_name_from_spec(added_specs[0])
if name is not None:
return name
if replace:
changed_sources = [name for name, source in _uv_sources(pyproject).items() if isinstance(name, str) and sources_before.get(name) != source]
if len(changed_sources) == 1:
return _normalize_distribution(changed_sources[0])
raise RuntimeError("could not identify the distribution added by uv")
def _extension_dependency_names(pyproject: Path) -> set[str]:
names: set[str] = set() names: set[str] = set()
for dependency in dependencies: for dependency in _extension_dependencies(pyproject):
if not isinstance(dependency, str): name = _distribution_name_from_spec(dependency)
continue if name is not None:
match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", dependency) names.add(name)
if match:
names.add(_normalize_distribution(match.group(1)))
return names return names
def _installed_git_distribution(source: str, pyproject: Path) -> str | None:
"""Return the extensions-group distribution already pinned to this Git repo."""
requested = _git_repository_identity(source)
if requested is None:
return None
installed = _extension_dependency_names(pyproject)
for name, declared in _uv_sources(pyproject).items():
if not isinstance(name, str) or not isinstance(declared, dict):
continue
git_url = declared.get("git")
if not isinstance(git_url, str):
continue
if _git_repository_identity(git_url) != requested:
continue
normalized = _normalize_distribution(name)
if normalized in installed:
return normalized
return None
_LOCK_LOCAL_PATH_KEYS = frozenset({"path", "directory", "editable", "virtual"}) _LOCK_LOCAL_PATH_KEYS = frozenset({"path", "directory", "editable", "virtual"})
_LOCK_LOCAL_URL_KEYS = frozenset({"registry", "url", "git"}) _LOCK_LOCAL_URL_KEYS = frozenset({"registry", "url", "git"})
_LOCK_LOCAL_SOURCE_VIOLATION = "uv.lock contains a local dependency source outside the backend Docker build context" _LOCK_LOCAL_SOURCE_VIOLATION = "uv.lock contains a local dependency source outside the backend Docker build context"

View File

@ -86,6 +86,14 @@ def test_root_makefile_exposes_extension_management_commands() -> None:
assert "uv run --frozen --no-group extensions" in install assert "uv run --frozen --no-group extensions" in install
assert "--yes" not in install assert "--yes" not in install
upgrade = _make_recipe(makefile, "extension-upgrade")
assert "deerflow extensions upgrade" in upgrade
assert "--source-env __deerflow_extension_source__" in upgrade
assert "DEER_FLOW_EXTENSION_SOURCE" not in upgrade
assert "$(SOURCE)" not in upgrade
assert "uv run --frozen --no-group extensions" in upgrade
assert "--yes" not in upgrade
for target, command in ( for target, command in (
("extension-list", "deerflow extensions list"), ("extension-list", "deerflow extensions list"),
("extension-enable", "deerflow extensions enable"), ("extension-enable", "deerflow extensions enable"),
@ -102,6 +110,7 @@ def test_extension_management_bootstrap_does_not_resolve_a_broken_extension_sour
for target in ( for target in (
"extension-install", "extension-install",
"extension-upgrade",
"extension-list", "extension-list",
"extension-enable", "extension-enable",
"extension-disable", "extension-disable",
@ -180,6 +189,7 @@ def test_root_extension_shortcuts_are_cross_platform_and_keep_trust_confirmation
for target in ( for target in (
"extension-install", "extension-install",
"extension-upgrade",
"extension-enable", "extension-enable",
"extension-disable", "extension-disable",
"extension-remove", "extension-remove",
@ -189,12 +199,17 @@ def test_root_extension_shortcuts_are_cross_platform_and_keep_trust_confirmation
assert "usage: make" in recipe, target assert "usage: make" in recipe, target
assert "--yes" not in _make_recipe(makefile, "extension-install") assert "--yes" not in _make_recipe(makefile, "extension-install")
assert "--yes" not in _make_recipe(makefile, "extension-upgrade")
def test_root_extension_shortcuts_reject_ambient_environment_arguments() -> None: def test_root_extension_shortcuts_reject_ambient_environment_arguments() -> None:
environment = os.environ.copy() environment = os.environ.copy()
for target, variable in (("extension-install", "SOURCE"), ("extension-enable", "NAME")): for target, variable in (
("extension-install", "SOURCE"),
("extension-upgrade", "SOURCE"),
("extension-enable", "NAME"),
):
environment[variable] = "ambient-value" environment[variable] = "ambient-value"
result = subprocess.run( result = subprocess.run(
["make", "--no-print-directory", "-n", target], ["make", "--no-print-directory", "-n", target],
@ -212,6 +227,7 @@ def test_root_extension_shortcuts_reject_ambient_environment_arguments() -> None
("target", "variable", "env_option"), ("target", "variable", "env_option"),
[ [
("extension-install", "SOURCE", "--source-env __deerflow_extension_source__"), ("extension-install", "SOURCE", "--source-env __deerflow_extension_source__"),
("extension-upgrade", "SOURCE", "--source-env __deerflow_extension_source__"),
("extension-enable", "NAME", "--name-env __deerflow_extension_name__"), ("extension-enable", "NAME", "--name-env __deerflow_extension_name__"),
("extension-disable", "NAME", "--name-env __deerflow_extension_name__"), ("extension-disable", "NAME", "--name-env __deerflow_extension_name__"),
("extension-remove", "NAME", "--name-env __deerflow_extension_name__"), ("extension-remove", "NAME", "--name-env __deerflow_extension_name__"),
@ -244,6 +260,7 @@ def test_root_extension_shortcuts_keep_command_line_arguments_out_of_the_shell_r
("target", "variable", "env_option"), ("target", "variable", "env_option"),
[ [
("extension-install", "SOURCE", "--source-env __deerflow_extension_source__"), ("extension-install", "SOURCE", "--source-env __deerflow_extension_source__"),
("extension-upgrade", "SOURCE", "--source-env __deerflow_extension_source__"),
("extension-enable", "NAME", "--name-env __deerflow_extension_name__"), ("extension-enable", "NAME", "--name-env __deerflow_extension_name__"),
("extension-disable", "NAME", "--name-env __deerflow_extension_name__"), ("extension-disable", "NAME", "--name-env __deerflow_extension_name__"),
("extension-remove", "NAME", "--name-env __deerflow_extension_name__"), ("extension-remove", "NAME", "--name-env __deerflow_extension_name__"),

View File

@ -166,13 +166,16 @@ def _assert_demo_entry_point_loads(backend: Path) -> None:
assert completed.returncode == 0, completed.stderr assert completed.returncode == 0, completed.stderr
def _write_demo_wheel(directory: Path) -> Path: def _write_demo_wheel(directory: Path, *, version: str = "1.0.0", marker: str | None = None) -> Path:
directory.mkdir() directory.mkdir(parents=True, exist_ok=True)
wheel = directory / "deerflow_extension_demo-1.0.0-py3-none-any.whl" wheel = directory / f"deerflow_extension_demo-{version}-py3-none-any.whl"
dist_info = "deerflow_extension_demo-1.0.0.dist-info" dist_info = f"deerflow_extension_demo-{version}.dist-info"
init = "def install(registry, config):\n return None\n"
if marker is not None:
init = f"MARKER = {marker!r}\n{init}"
records = { records = {
"demo_extension/__init__.py": "def install(registry, config):\n return None\n", "demo_extension/__init__.py": init,
f"{dist_info}/METADATA": ("Metadata-Version: 2.1\nName: deerflow-extension-demo\nVersion: 1.0.0\nRequires-Python: >=3.12\n"), f"{dist_info}/METADATA": (f"Metadata-Version: 2.1\nName: deerflow-extension-demo\nVersion: {version}\nRequires-Python: >=3.12\n"),
f"{dist_info}/WHEEL": ("Wheel-Version: 1.0\nGenerator: deerflow-extension-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n"), f"{dist_info}/WHEEL": ("Wheel-Version: 1.0\nGenerator: deerflow-extension-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n"),
f"{dist_info}/entry_points.txt": ("[deerflow.extensions]\ndemo = demo_extension:install\n"), f"{dist_info}/entry_points.txt": ("[deerflow.extensions]\ndemo = demo_extension:install\n"),
} }
@ -219,6 +222,389 @@ def test_install_local_directory_makes_it_deployable_and_enabled(tmp_path: Path)
_assert_demo_entry_point_loads(root / "backend") _assert_demo_entry_point_loads(root / "backend")
def test_install_rejects_an_already_snapshotted_local_directory(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
manager = ExtensionManager(root)
manager.install(str(source), yes=True)
with pytest.raises(FileExistsError, match="already installed"):
manager.install(str(source), yes=True)
def test_upgrade_replaces_local_snapshot_and_preserves_private_config(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
manager = ExtensionManager(root)
manager.install(str(source), yes=True)
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config["plugins"][0]["required"] = True
config["plugins"][0]["config"] = {"label": "keep-this"}
config["plugins"][0]["enabled"] = False
config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v2'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
result = manager.upgrade(str(source), yes=True)
assert result.name == "demo"
managed = root / "backend" / "extensions" / "sources" / "deerflow-extension-demo"
assert "MARKER = 'v2'" in (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8")
plugins = yaml.safe_load(config_path.read_text(encoding="utf-8"))["plugins"]
assert plugins == [
{
"name": "demo",
"package": "deerflow-extension-demo",
"use": "demo_extension:install",
"enabled": False,
"required": True,
"config": {"label": "keep-this"},
}
]
_assert_demo_entry_point_loads(root / "backend")
def test_failed_upgrade_restores_the_previous_snapshot_and_config(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
manager = ExtensionManager(root)
manager.install(str(source), yes=True)
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config["plugins"][0]["config"] = {"label": "keep-this"}
original_config = yaml.safe_dump(config, sort_keys=False)
config_path.write_text(original_config, encoding="utf-8")
original_init = (root / "backend" / "extensions" / "sources" / "deerflow-extension-demo" / "demo_extension" / "__init__.py").read_text(encoding="utf-8")
original_pyproject = (root / "backend" / "pyproject.toml").read_bytes()
broken = tmp_path / "broken-source"
broken.mkdir()
_write_local_extension(broken, entry_target="missing_demo_extension:install")
with pytest.raises(ValueError, match="could not be loaded"):
manager.upgrade(str(broken), yes=True)
managed = root / "backend" / "extensions" / "sources" / "deerflow-extension-demo"
assert (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8") == original_init
assert (root / "backend" / "pyproject.toml").read_bytes() == original_pyproject
assert yaml.safe_load(config_path.read_text(encoding="utf-8"))["plugins"][0]["config"] == {"label": "keep-this"}
leftover = list((root / "backend" / "extensions" / "sources").glob(".*.upgrade-*"))
assert leftover == []
def test_deerflow_extensions_upgrade_exposes_the_local_replace_flow(
tmp_path: Path,
monkeypatch,
capsys,
) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(root))
assert deerflow_main(["extensions", "install", str(source), "--yes"]) == 0
capsys.readouterr()
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v2'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
exit_code = deerflow_main(["extensions", "upgrade", str(source), "--yes"])
assert exit_code == 0
assert "Upgraded demo" in capsys.readouterr().out
managed = root / "backend" / "extensions" / "sources" / "deerflow-extension-demo"
assert "MARKER = 'v2'" in (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8")
def test_upgrade_rejects_a_local_source_that_is_not_installed(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
with pytest.raises(ValueError, match="not installed"):
ExtensionManager(root).upgrade(str(source), yes=True)
assert not (root / "backend" / "extensions" / "sources" / "deerflow-extension-demo").exists()
assert yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8")).get("plugins") is None
def test_upgrade_rejects_a_requirement_that_is_not_installed(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
root.mkdir()
_write_host_project(root)
with pytest.raises(ValueError, match="not installed"):
ExtensionManager(root).upgrade("deerflow-extension-demo==2.0.0", yes=True)
def test_upgrade_rejects_a_git_source_that_is_not_installed(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
root.mkdir()
_write_host_project(root)
pyproject = root / "backend" / "pyproject.toml"
before = pyproject.read_text(encoding="utf-8")
with pytest.raises(ValueError, match="not installed"):
ExtensionManager(root).upgrade(
"git+https://github.com/acme/deerflow-extension-demo.git@main",
yes=True,
)
assert pyproject.read_text(encoding="utf-8") == before
assert yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8")).get("plugins") is None
def test_upgrade_repins_an_installed_git_source_and_preserves_private_config(tmp_path: Path) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-git-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v1'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
first_revision = _commit_local_extension(source)
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v2'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
test_hooks = source / ".git" / "test-hooks"
subprocess.run(["git", "add", "."], cwd=source, check=True)
subprocess.run(
["git", "-c", f"core.hooksPath={test_hooks}", "commit", "-qm", "upgrade pin"],
cwd=source,
check=True,
)
second_revision = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=source,
check=True,
capture_output=True,
text=True,
).stdout.strip()
bare_repository = tmp_path / "demo.git"
subprocess.run(["git", "clone", "-q", "--bare", str(source), str(bare_repository)], check=True)
subprocess.run(["git", "--git-dir", str(bare_repository), "update-server-info"], check=True)
with _serve_directory(tmp_path) as base_url:
manager = ExtensionManager(root)
manager.install(f"git+{base_url}/demo.git@{first_revision}", yes=True)
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config["plugins"][0]["required"] = True
config["plugins"][0]["config"] = {"label": "keep-this"}
config["plugins"][0]["enabled"] = False
config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
result = manager.upgrade(f"git+{base_url}/demo.git@{second_revision}", yes=True)
_assert_demo_entry_point_loads(root / "backend")
marker = subprocess.run(
[
str(root / "backend" / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
"-c",
"import demo_extension; print(demo_extension.MARKER)",
],
check=True,
capture_output=True,
text=True,
).stdout.strip()
assert result.name == "demo"
assert marker == "v2"
assert second_revision in (root / "backend" / "uv.lock").read_text(encoding="utf-8")
assert not (root / "backend" / "extensions" / "sources" / "deerflow-extension-demo").exists()
plugins = yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8"))["plugins"]
assert plugins == [
{
"name": "demo",
"package": "deerflow-extension-demo",
"use": "demo_extension:install",
"enabled": False,
"required": True,
"config": {"label": "keep-this"},
}
]
def test_upgrade_repins_an_installed_requirement_and_preserves_private_config(
tmp_path: Path,
monkeypatch,
) -> None:
"""Re-pinning ==2.0.0 to ==3.0.0 keeps the same distribution name.
added_names is empty; identification must take the added_specs fallback so
private config/required/enabled survive the lock re-pin.
"""
root = tmp_path / "deer-flow"
simple_root = tmp_path / "simple"
package_dir = simple_root / "deerflow-extension-demo"
root.mkdir()
_write_host_project(root)
_write_demo_wheel(package_dir, version="2.0.0", marker="v2")
_write_demo_wheel(package_dir, version="3.0.0", marker="v3")
(package_dir / "index.html").write_text(
"""\
<!DOCTYPE html>
<html><body>
<a href="deerflow_extension_demo-2.0.0-py3-none-any.whl">deerflow_extension_demo-2.0.0-py3-none-any.whl</a>
<a href="deerflow_extension_demo-3.0.0-py3-none-any.whl">deerflow_extension_demo-3.0.0-py3-none-any.whl</a>
</body></html>
""",
encoding="utf-8",
)
with _serve_directory(simple_root) as index_url:
monkeypatch.setenv("UV_DEFAULT_INDEX", index_url)
manager = ExtensionManager(root)
manager.install("deerflow-extension-demo==2.0.0", yes=True)
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config["plugins"][0]["required"] = True
config["plugins"][0]["config"] = {"label": "keep-this"}
config["plugins"][0]["enabled"] = False
config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
result = manager.upgrade("deerflow-extension-demo==3.0.0", yes=True)
_assert_demo_entry_point_loads(root / "backend")
marker = subprocess.run(
[
str(root / "backend" / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
"-c",
"import demo_extension; print(demo_extension.MARKER)",
],
check=True,
capture_output=True,
text=True,
).stdout.strip()
pyproject = (root / "backend" / "pyproject.toml").read_text(encoding="utf-8")
lock = (root / "backend" / "uv.lock").read_text(encoding="utf-8")
assert result.name == "demo"
assert marker == "v3"
assert "deerflow-extension-demo==3.0.0" in pyproject
assert "deerflow-extension-demo==2.0.0" not in pyproject
assert re.search(r'name = "deerflow-extension-demo"\s+version = "3.0.0"', lock) is not None
assert not (root / "backend" / "extensions" / "sources" / "deerflow-extension-demo").exists()
plugins = yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8"))["plugins"]
assert plugins == [
{
"name": "demo",
"package": "deerflow-extension-demo",
"use": "demo_extension:install",
"enabled": False,
"required": True,
"config": {"label": "keep-this"},
}
]
def test_failed_upgrade_leaves_snapshot_when_staging_rename_fails(tmp_path: Path, monkeypatch) -> None:
"""A snapshot that cannot be moved must stay the live tree.
Path.rename can fail after mkdtemp (file held open, Windows AV). Treating
that like a failed install would rmtree the original snapshot that was
never replaced.
"""
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
manager = ExtensionManager(root)
manager.install(str(source), yes=True)
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config["plugins"][0]["config"] = {"label": "keep-this"}
config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
managed = root / "backend" / "extensions" / "sources" / "deerflow-extension-demo"
original_init = (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8")
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v2'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
original_rename = Path.rename
def _rename(self, target):
if self.resolve() == managed.resolve():
raise OSError("snapshot file in use")
return original_rename(self, target)
monkeypatch.setattr("deerflow.extensions.manager.Path.rename", _rename)
with pytest.raises(OSError, match="snapshot file in use"):
manager.upgrade(str(source), yes=True)
assert (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8") == original_init
assert yaml.safe_load(config_path.read_text(encoding="utf-8"))["plugins"][0]["config"] == {"label": "keep-this"}
assert list((root / "backend" / "extensions" / "sources").glob(".*.upgrade-*")) == []
def test_failed_upgrade_restores_snapshot_when_a_concurrent_dependency_edit_blocks_lock_rollback(
tmp_path: Path,
monkeypatch,
) -> None:
root = tmp_path / "deer-flow"
source = tmp_path / "demo-source"
root.mkdir()
source.mkdir()
_write_host_project(root)
_write_local_extension(source)
manager = ExtensionManager(root)
manager.install(str(source), yes=True)
original_init = (root / "backend" / "extensions" / "sources" / "deerflow-extension-demo" / "demo_extension" / "__init__.py").read_text(encoding="utf-8")
pyproject_path = root / "backend" / "pyproject.toml"
(source / "demo_extension" / "__init__.py").write_text(
"MARKER = 'v2'\ndef install(registry, config):\n return None\n",
encoding="utf-8",
)
def _fail_after_operator_edit(*_args, **_kwargs):
pyproject_path.write_text(
pyproject_path.read_text(encoding="utf-8") + "\n# operator edit during upgrade\n",
encoding="utf-8",
)
raise RuntimeError("simulated dependency sync failure")
monkeypatch.setattr("deerflow.extensions.manager._sync_environment", _fail_after_operator_edit)
with pytest.raises(RuntimeError, match="recovery.*dependency"):
manager.upgrade(str(source), yes=True)
managed = root / "backend" / "extensions" / "sources" / "deerflow-extension-demo"
assert (managed / "demo_extension" / "__init__.py").read_text(encoding="utf-8") == original_init
assert "# operator edit during upgrade" in pyproject_path.read_text(encoding="utf-8")
assert list((root / "backend" / "extensions" / "sources").glob(".*.upgrade-*")) == []
def test_install_defaults_to_a_fail_open_plugin_record(tmp_path: Path) -> None: def test_install_defaults_to_a_fail_open_plugin_record(tmp_path: Path) -> None:
"""A managed install must not silently choose the fail-closed side: with """A managed install must not silently choose the fail-closed side: with
`required: true`, a later broken extension aborts Gateway startup entirely, `required: true`, a later broken extension aborts Gateway startup entirely,
@ -292,7 +678,7 @@ def test_mutating_operations_are_serialized_for_one_checkout(tmp_path: Path, mon
release_first = threading.Event() release_first = threading.Event()
second_entered = threading.Event() second_entered = threading.Event()
def _fake_install(self, source: str, *, yes: bool, required: bool): def _fake_install(self, source: str, *, yes: bool, required: bool, replace: bool = False):
if source == "first": if source == "first":
first_entered.set() first_entered.set()
assert release_first.wait(timeout=5) assert release_first.wait(timeout=5)