mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
fix(code): preserve uv tool context when installing extras (#4201)
Split extra installation into a display path and an execution path so user-facing recovery instructions match the public install script, while the in-app uv flow still preserves the existing tool environment. This avoids dropping the recorded Python interpreter or `--with` packages when `/install <extra>` rewrites the uv-managed `dcode` tool. ## Changes - Add `INSTALL_SCRIPT_COMMAND` and make `install_extras_command` / `install_extra_command` display the promoted `curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS=... bash` reinstall flow instead of raw `uv tool install` - Add `_install_extra_uv_tool_command` for the actual uv-managed execution path, merging the requested extra with installed extras through `_uv_tool_install_command` - Extend `_uv_tool_install_command` with `extras_to_add`, preserving the uv receipt’s `--python` interpreter and existing `--with` packages while adding the new extra - Update `perform_install_extra` to use `_install_extra_uv_tool_command` for uv-managed installs and report `ToolRequirementIntrospectionError` when the receipt cannot be safely preserved - Keep unsupported install guidance in `perform_install_extra` receipt-free, so Homebrew/unknown installs get the install-script recovery command without failing on uv receipt parsing - Update `DeepAgentsApp._install_extra` and the `--install` CLI fallback path to surface the install-script command in user-facing manual recovery messages
This commit is contained in:
@@ -4388,9 +4388,11 @@ class DeepAgentsApp(App):
|
||||
ExtrasIntrospectionError,
|
||||
)
|
||||
from deepagents_code.update_check import (
|
||||
ToolRequirementIntrospectionError,
|
||||
create_update_log_path,
|
||||
editable_extra_hint,
|
||||
install_extra_command,
|
||||
install_extra_recovery_command,
|
||||
is_valid_extra_name,
|
||||
perform_install_extra,
|
||||
)
|
||||
@@ -4425,7 +4427,11 @@ class DeepAgentsApp(App):
|
||||
if extra not in KNOWN_EXTRAS and not force:
|
||||
try:
|
||||
manual_cmd = await asyncio.to_thread(install_extra_command, extra)
|
||||
except (ExtrasIntrospectionError, ValueError) as exc:
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
logger.warning("/install command failed", exc_info=True)
|
||||
await self._mount_message(
|
||||
ErrorMessage(f"Install failed: {type(exc).__name__}: {exc}"),
|
||||
@@ -4452,7 +4458,11 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
try:
|
||||
manual_cmd = await asyncio.to_thread(install_extra_command, extra)
|
||||
except (ExtrasIntrospectionError, ValueError) as exc:
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
logger.warning("/install command failed", exc_info=True)
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
@@ -4464,6 +4474,24 @@ class DeepAgentsApp(App):
|
||||
success, output = await perform_install_extra(extra, log_path=log_path)
|
||||
except (OSError, asyncio.CancelledError) as exc:
|
||||
logger.warning("/install command failed", exc_info=True)
|
||||
# Best-effort upgrade of `manual_cmd` to the install-method-specific
|
||||
# recovery command. On failure, keep the install-script command
|
||||
# already bound above so the hint is never empty. `manual_cmd` is
|
||||
# rendered into a Textual `Content` (literal, not Rich markup), so no
|
||||
# bracket escaping is needed here.
|
||||
try:
|
||||
manual_cmd = await asyncio.to_thread(
|
||||
install_extra_recovery_command, extra
|
||||
)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
):
|
||||
logger.warning(
|
||||
"/install recovery command failed (install raised)",
|
||||
exc_info=True,
|
||||
)
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
f"Install failed: {type(exc).__name__}: {exc}\n"
|
||||
@@ -4477,6 +4505,21 @@ class DeepAgentsApp(App):
|
||||
# Tail the last 200 chars — uv resolver prints the resolved
|
||||
# error at the end, not the beginning.
|
||||
detail = f": {output[-200:]}" if output else ""
|
||||
# See the OSError branch above: best-effort recovery command, falling
|
||||
# back to the already-bound install-script command on failure.
|
||||
try:
|
||||
manual_cmd = await asyncio.to_thread(
|
||||
install_extra_recovery_command, extra
|
||||
)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
):
|
||||
logger.warning(
|
||||
"/install recovery command failed (install reported failure)",
|
||||
exc_info=True,
|
||||
)
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
f"Install failed{detail}\n"
|
||||
|
||||
@@ -2722,11 +2722,17 @@ def cli_main() -> None:
|
||||
from rich.markup import escape
|
||||
|
||||
from deepagents_code.config import _is_editable_install
|
||||
from deepagents_code.extras_info import KNOWN_EXTRAS
|
||||
from deepagents_code.extras_info import (
|
||||
KNOWN_EXTRAS,
|
||||
ExtrasIntrospectionError,
|
||||
)
|
||||
from deepagents_code.update_check import (
|
||||
ToolRequirementIntrospectionError,
|
||||
create_update_log_path,
|
||||
editable_extra_hint,
|
||||
install_extra_command,
|
||||
install_extra_recovery_command,
|
||||
install_extras_command,
|
||||
is_valid_extra_name,
|
||||
perform_install_extra,
|
||||
)
|
||||
@@ -2799,10 +2805,24 @@ def cli_main() -> None:
|
||||
# Tail the last 200 chars — uv resolver prints the resolved
|
||||
# error at the end, not the beginning.
|
||||
detail = f": {output[-200:]}" if output else ""
|
||||
try:
|
||||
manual_cmd = install_extra_recovery_command(extra)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
):
|
||||
logger.warning(
|
||||
"--install recovery command failed (install reported failure)",
|
||||
exc_info=True,
|
||||
)
|
||||
# Keep the install-script command bound above; fall back to a
|
||||
# bare extras command only if that was never set.
|
||||
manual_cmd = manual_cmd or install_extras_command((extra,))
|
||||
console.print(
|
||||
f"[bold red]Install failed[/bold red]{escape(detail)}\n"
|
||||
f"Log: {log_path}\n"
|
||||
f"Run manually: [cyan]{manual_cmd}[/cyan]",
|
||||
f"Run manually: [cyan]{escape(manual_cmd)}[/cyan]",
|
||||
markup=True,
|
||||
highlight=False,
|
||||
)
|
||||
@@ -2813,9 +2833,19 @@ def cli_main() -> None:
|
||||
except Exception as exc:
|
||||
logger.warning("--install failed", exc_info=True)
|
||||
log_line = f"\nLog: {log_path}" if log_path else ""
|
||||
fallback_cmd = (
|
||||
manual_cmd or f"uv tool install -U 'deepagents-code[{extra}]'"
|
||||
)
|
||||
# This is the catch-all for any unexpected install failure, so
|
||||
# the recovery-hint guard is intentionally broad too: it must
|
||||
# never raise a second error over the original one. `manual_cmd`
|
||||
# may be unset here (the failure could predate its assignment),
|
||||
# so fall back to a bare extras command.
|
||||
try:
|
||||
fallback_cmd = install_extra_recovery_command(extra)
|
||||
except Exception: # best-effort hint, never re-raise here
|
||||
logger.warning(
|
||||
"--install recovery command failed (unexpected error)",
|
||||
exc_info=True,
|
||||
)
|
||||
fallback_cmd = manual_cmd or install_extras_command((extra,))
|
||||
console.print(
|
||||
f"[bold red]Error:[/bold red] "
|
||||
f"{type(exc).__name__}: {escape(str(exc))}"
|
||||
|
||||
@@ -129,6 +129,9 @@ since that one-liner is the path we promote.
|
||||
_UPGRADE_TIMEOUT = 120 # seconds
|
||||
"""Wall-clock cap for `perform_upgrade` and `perform_install_extra`."""
|
||||
|
||||
INSTALL_SCRIPT_COMMAND = "curl -LsSf https://langch.in/dcode | bash"
|
||||
"""Promoted public install command for Deep Agents Code."""
|
||||
|
||||
UPDATE_LOG_DIR: Path = DEFAULT_STATE_DIR / "update_logs"
|
||||
"""Directory for persisted update command logs."""
|
||||
|
||||
@@ -1872,19 +1875,32 @@ def _uv_tool_install_command(
|
||||
version: str | None,
|
||||
include_prereleases: bool | None,
|
||||
distribution_name: str,
|
||||
extras_to_add: Iterable[str] = (),
|
||||
) -> str:
|
||||
"""Return the receipt-preserving `uv tool install -U` command.
|
||||
|
||||
Args:
|
||||
version: Optional exact `deepagents-code` version pin.
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
distribution_name: Name of the installed distribution to inspect.
|
||||
extras_to_add: Extra names to merge with already-installed extras.
|
||||
|
||||
Raises:
|
||||
ExtrasIntrospectionError: If a metadata-sourced extra name fails PEP 508
|
||||
validation.
|
||||
|
||||
Propagates `ToolRequirementIntrospectionError` if the uv tool receipt's
|
||||
interpreter or `--with` packages cannot be determined safely from the tool
|
||||
receipt.
|
||||
"""
|
||||
from deepagents_code.extras_info import (
|
||||
ExtrasIntrospectionError,
|
||||
installed_extra_names,
|
||||
)
|
||||
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
extras = set(installed_extra_names(distribution_name, strict=True))
|
||||
extras.update(extras_to_add)
|
||||
try:
|
||||
requirement = _dcode_extras_requirement(extras, version=version)
|
||||
except ValueError as exc:
|
||||
@@ -2077,7 +2093,7 @@ def install_package_command(
|
||||
|
||||
|
||||
def install_extras_command(extras: Iterable[str]) -> str:
|
||||
"""Return the uv command that installs the exact set of dcode extras.
|
||||
"""Return the install-script command that installs dcode extras.
|
||||
|
||||
Args:
|
||||
extras: Extra names to include in the tool reinstall. Validated by
|
||||
@@ -2085,10 +2101,16 @@ def install_extras_command(extras: Iterable[str]) -> str:
|
||||
that fails PEP 508 validation.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for display in error messages and
|
||||
execution via `perform_install_extra`.
|
||||
Shell command string suitable for display in error messages.
|
||||
"""
|
||||
return f"uv tool install -U {_dcode_extras_requirement(extras)}"
|
||||
names = sorted(extras)
|
||||
_dcode_extras_requirement(names)
|
||||
if not names:
|
||||
return INSTALL_SCRIPT_COMMAND
|
||||
extras_env = shlex.quote(",".join(names))
|
||||
return (
|
||||
f"curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS={extras_env} bash"
|
||||
)
|
||||
|
||||
|
||||
def install_extra_command(
|
||||
@@ -2096,11 +2118,13 @@ def install_extra_command(
|
||||
*,
|
||||
distribution_name: str = "deepagents-code",
|
||||
) -> str:
|
||||
"""Return the shell command that adds `extra` to the installed dcode tool.
|
||||
"""Return the install-script command that adds `extra` to dcode.
|
||||
|
||||
The documented install path is `uv tool install` (see
|
||||
`scripts/install.sh`), so extras must be preserved across reinstalls.
|
||||
Single-quoting the bracket form keeps zsh from globbing it.
|
||||
The promoted install path is the install script (see `scripts/install.sh`).
|
||||
This helper is display-only and avoids uv receipt introspection so
|
||||
unsupported installs can surface method-specific guidance before any uv
|
||||
receipt is read. Already-detected extras from distribution metadata are
|
||||
included when available, so following the command does not drop them.
|
||||
|
||||
Args:
|
||||
extra: The extra name (e.g. `'quickjs'`, `'daytona'`, `'fireworks'`).
|
||||
@@ -2110,35 +2134,83 @@ def install_extra_command(
|
||||
already-installed extras.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for display in error messages and
|
||||
for execution via `perform_install_extra`.
|
||||
Shell command string suitable for display in error messages.
|
||||
|
||||
Raises:
|
||||
ExtrasIntrospectionError: If installed extras cannot be determined
|
||||
safely from distribution metadata.
|
||||
ValueError: If `extra` or any already-installed extra fails PEP 508
|
||||
validation.
|
||||
ValueError: If `extra` fails PEP 508 validation.
|
||||
"""
|
||||
from deepagents_code.extras_info import (
|
||||
ExtrasIntrospectionError,
|
||||
installed_extra_names,
|
||||
)
|
||||
|
||||
if not is_valid_extra_name(extra):
|
||||
msg = (
|
||||
f"Invalid extra name {extra!r}: must match PEP 508 "
|
||||
f"({_EXTRA_NAME_RE.pattern})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
except ExtrasIntrospectionError as exc:
|
||||
msg = str(exc)
|
||||
raise ExtrasIntrospectionError(msg) from exc
|
||||
from deepagents_code.extras_info import installed_extra_names
|
||||
|
||||
extras = installed_extra_names(distribution_name)
|
||||
extras.add(extra)
|
||||
return install_extras_command(extras)
|
||||
|
||||
|
||||
def install_extra_recovery_command(extra: str) -> str:
|
||||
"""Return a manual recovery command for the current install method.
|
||||
|
||||
uv-managed installs can preserve the uv receipt's Python interpreter and
|
||||
`--with` requirements, so their recovery command uses the same uv path as
|
||||
the automatic installer. Unsupported methods keep the install-script command
|
||||
and deliberately avoid reading uv receipts.
|
||||
|
||||
Args:
|
||||
extra: Extra name to add.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for display in error messages.
|
||||
|
||||
Propagates `ValueError` if `extra` fails PEP 508 validation, and (on the uv
|
||||
path) `ExtrasIntrospectionError` if installed extras cannot be determined
|
||||
safely or `ToolRequirementIntrospectionError` if the uv receipt's
|
||||
interpreter or `--with` packages cannot be preserved safely.
|
||||
"""
|
||||
if detect_install_method() == "uv":
|
||||
return _install_extra_uv_tool_command(extra)
|
||||
return install_extra_command(extra)
|
||||
|
||||
|
||||
def _install_extra_uv_tool_command(
|
||||
extra: str,
|
||||
*,
|
||||
distribution_name: str = "deepagents-code",
|
||||
) -> str:
|
||||
"""Return the receipt-preserving uv command that installs one dcode extra.
|
||||
|
||||
Args:
|
||||
extra: The extra name to add. Validated against PEP 508 grammar before
|
||||
interpolation into the shell command.
|
||||
distribution_name: Name of the installed distribution to inspect for
|
||||
already-installed extras and uv receipt requirements.
|
||||
|
||||
Raises:
|
||||
ValueError: If `extra` fails PEP 508 validation.
|
||||
|
||||
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
||||
determined safely from distribution metadata, and
|
||||
`ToolRequirementIntrospectionError` if the uv tool receipt's interpreter or
|
||||
`--with` packages cannot be preserved safely.
|
||||
"""
|
||||
if not is_valid_extra_name(extra):
|
||||
msg = (
|
||||
f"Invalid extra name {extra!r}: must match PEP 508 "
|
||||
f"({_EXTRA_NAME_RE.pattern})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return _uv_tool_install_command(
|
||||
version=None,
|
||||
include_prereleases=None,
|
||||
distribution_name=distribution_name,
|
||||
extras_to_add=(extra,),
|
||||
)
|
||||
|
||||
|
||||
def editable_extra_hint(extra: str) -> str:
|
||||
"""Return the canonical action hint for editable installs missing an extra.
|
||||
|
||||
@@ -2209,15 +2281,15 @@ async def perform_install_extra(
|
||||
# right escape hatch but would conflict with the brew-managed binary.
|
||||
return False, (
|
||||
"Homebrew install detected — extras are not supported via brew. "
|
||||
"Reinstall with `uv tool install -U 'deepagents-code["
|
||||
f"{extra}]'` to switch to a uv-managed tool install with extras."
|
||||
f"Reinstall with `{install_extra_command(extra)}` to switch to a "
|
||||
"uv-managed tool install with extras."
|
||||
)
|
||||
if method == "other":
|
||||
return False, (
|
||||
"Unsupported install method detected — cannot add extras without "
|
||||
"knowing which environment provides `dcode`. Reinstall with "
|
||||
f"`uv tool install -U 'deepagents-code[{extra}]'` to switch to a "
|
||||
"uv-managed tool install with extras."
|
||||
f"`{install_extra_command(extra)}` to switch to a uv-managed tool "
|
||||
"install with extras."
|
||||
)
|
||||
|
||||
if not shutil.which("uv"):
|
||||
@@ -2229,8 +2301,12 @@ async def perform_install_extra(
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError
|
||||
|
||||
try:
|
||||
cmd = install_extra_command(extra)
|
||||
except (ExtrasIntrospectionError, ValueError) as exc:
|
||||
cmd = _install_extra_uv_tool_command(extra)
|
||||
except (
|
||||
ExtrasIntrospectionError,
|
||||
ToolRequirementIntrospectionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
||||
|
||||
|
||||
@@ -7732,6 +7732,14 @@ class TestInstallExtraModelSwitch:
|
||||
monkeypatch.setattr(
|
||||
update_check, "install_extra_command", lambda extra: f"uv install {extra}"
|
||||
)
|
||||
# Inert stub: install succeeds below, so the recovery command (only
|
||||
# built on failure) is never invoked. Patched to guard against an
|
||||
# accidental real call introspecting the host's install state.
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"install_extra_recovery_command",
|
||||
lambda extra: f"uv install {extra}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"perform_install_extra",
|
||||
@@ -7771,6 +7779,14 @@ class TestInstallExtraModelSwitch:
|
||||
monkeypatch.setattr(
|
||||
update_check, "install_extra_command", lambda extra: f"uv install {extra}"
|
||||
)
|
||||
# Inert stub: install succeeds below, so the recovery command (only
|
||||
# built on failure) is never invoked. Patched to guard against an
|
||||
# accidental real call introspecting the host's install state.
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"install_extra_recovery_command",
|
||||
lambda extra: f"uv install {extra}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"perform_install_extra",
|
||||
@@ -7808,6 +7824,14 @@ class TestInstallExtraModelSwitch:
|
||||
monkeypatch.setattr(
|
||||
update_check, "install_extra_command", lambda extra: f"uv install {extra}"
|
||||
)
|
||||
# Inert stub: install succeeds below, so the recovery command (only
|
||||
# built on failure) is never invoked. Patched to guard against an
|
||||
# accidental real call introspecting the host's install state.
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"install_extra_recovery_command",
|
||||
lambda extra: f"uv install {extra}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"perform_install_extra",
|
||||
@@ -7843,6 +7867,14 @@ class TestInstallExtraModelSwitch:
|
||||
monkeypatch.setattr(
|
||||
update_check, "install_extra_command", lambda extra: f"uv install {extra}"
|
||||
)
|
||||
# Inert stub: install succeeds below, so the recovery command (only
|
||||
# built on failure) is never invoked. Patched to guard against an
|
||||
# accidental real call introspecting the host's install state.
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"install_extra_recovery_command",
|
||||
lambda extra: f"uv install {extra}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"perform_install_extra",
|
||||
@@ -7933,6 +7965,11 @@ class TestInstallExtraModelSwitch:
|
||||
monkeypatch.setattr(
|
||||
update_check, "install_extra_command", lambda extra: f"uv install {extra}"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"install_extra_recovery_command",
|
||||
lambda extra: f"uv install {extra}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_check,
|
||||
"perform_install_extra",
|
||||
|
||||
@@ -14,6 +14,10 @@ import pytest
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.widgets.messages import AppMessage, ErrorMessage
|
||||
|
||||
MANUAL_EXTRA_COMMAND = (
|
||||
"curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS=quickjs bash"
|
||||
)
|
||||
|
||||
|
||||
async def test_install_slash_usage_when_no_extra() -> None:
|
||||
"""`/install` with no argument prints a usage hint plus the valid extras."""
|
||||
@@ -293,6 +297,14 @@ async def test_install_slash_failure_surfaces_log_path_and_manual_cmd() -> None:
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value="/tmp/deepagents-install.log",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
@@ -306,7 +318,8 @@ async def test_install_slash_failure_surfaces_log_path_and_manual_cmd() -> None:
|
||||
assert "Install failed" in joined
|
||||
assert "resolver: conflict" in joined
|
||||
assert "/tmp/deepagents-install.log" in joined
|
||||
assert "uv tool install -U 'deepagents-code" in joined
|
||||
assert "curl -LsSf https://langch.in/dcode" in joined
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in joined
|
||||
assert "quickjs" in joined
|
||||
|
||||
|
||||
@@ -321,6 +334,14 @@ async def test_install_slash_exception_surfaces_log_path_and_manual_cmd() -> Non
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value="/tmp/deepagents-install.log",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
@@ -334,10 +355,120 @@ async def test_install_slash_exception_surfaces_log_path_and_manual_cmd() -> Non
|
||||
assert "OSError" in joined
|
||||
assert "disk full" in joined
|
||||
assert "/tmp/deepagents-install.log" in joined
|
||||
assert "uv tool install -U 'deepagents-code" in joined
|
||||
assert "curl -LsSf https://langch.in/dcode" in joined
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in joined
|
||||
assert "quickjs" in joined
|
||||
|
||||
|
||||
async def test_install_slash_failure_renders_recovery_bracket_literally() -> None:
|
||||
"""A uv recovery command's `[extra]` bracket renders literally in the TUI.
|
||||
|
||||
The TUI mounts recovery commands as Textual `Content`, so — unlike the
|
||||
Rich-markup CLI path — the bracket must not be backslash-escaped.
|
||||
"""
|
||||
uv_cmd = "uv tool install -U 'deepagents-code[quickjs]'"
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value="/tmp/deepagents-install.log",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
return_value=uv_cmd,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(False, "resolver: conflict"),
|
||||
),
|
||||
):
|
||||
await app._handle_command("/install quickjs")
|
||||
await pilot.pause()
|
||||
joined = "\n".join(str(m._content) for m in app.query(ErrorMessage))
|
||||
assert "deepagents-code[quickjs]" in joined
|
||||
assert "deepagents-code\\[quickjs]" not in joined
|
||||
|
||||
|
||||
async def test_install_slash_failure_recovery_error_keeps_prior_command() -> None:
|
||||
"""A recovery-command error on a failed install keeps the prior command.
|
||||
|
||||
The TUI shows the command resolved before the failure rather than crashing
|
||||
or showing nothing.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value="/tmp/deepagents-install.log",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
side_effect=ValueError("bad receipt"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(False, "resolver: conflict"),
|
||||
),
|
||||
):
|
||||
await app._handle_command("/install quickjs")
|
||||
await pilot.pause()
|
||||
joined = "\n".join(str(m._content) for m in app.query(ErrorMessage))
|
||||
assert "Install failed" in joined
|
||||
assert MANUAL_EXTRA_COMMAND in joined
|
||||
|
||||
|
||||
async def test_install_slash_exception_recovery_error_keeps_prior_command() -> None:
|
||||
"""A raised install plus a failed recovery command keeps the prior command.
|
||||
|
||||
When `perform_install_extra` raises and the recovery command also fails, the
|
||||
TUI still surfaces the command resolved before the failure.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value="/tmp/deepagents-install.log",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
return_value=MANUAL_EXTRA_COMMAND,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
side_effect=ValueError("bad receipt"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=OSError("disk full"),
|
||||
),
|
||||
):
|
||||
await app._handle_command("/install quickjs")
|
||||
await pilot.pause()
|
||||
joined = "\n".join(str(m._content) for m in app.query(ErrorMessage))
|
||||
assert "OSError" in joined
|
||||
assert MANUAL_EXTRA_COMMAND in joined
|
||||
|
||||
|
||||
async def test_install_slash_editable_install_refuses() -> None:
|
||||
"""Editable installs must not invoke `perform_install_extra` from the TUI.
|
||||
|
||||
|
||||
@@ -1715,7 +1715,10 @@ class TestInstallExtraSubcommand:
|
||||
# the handler's `isatty()` refusal check.
|
||||
mock_stdin.read.return_value = ""
|
||||
command_mock = MagicMock(
|
||||
return_value=f"uv tool install -U 'deepagents-code[{extra}]'",
|
||||
return_value=(
|
||||
"curl -LsSf https://langch.in/dcode | "
|
||||
f"DEEPAGENTS_CODE_EXTRAS={extra} bash"
|
||||
),
|
||||
)
|
||||
if command_side_effect is not None:
|
||||
command_mock.side_effect = command_side_effect
|
||||
@@ -1743,6 +1746,10 @@ class TestInstallExtraSubcommand:
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
command_mock,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
command_mock,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
new_callable=AsyncMock,
|
||||
@@ -1800,10 +1807,19 @@ class TestInstallExtraSubcommand:
|
||||
perform_return: tuple[bool, str] = (True, ""),
|
||||
perform_side_effect: BaseException | None = None,
|
||||
command_side_effect: BaseException | None = None,
|
||||
command_return: str | None = None,
|
||||
recovery_side_effect: BaseException | None = None,
|
||||
recovery_return: str | None = None,
|
||||
input_reply: str = "n",
|
||||
) -> tuple[int, MagicMock, MagicMock]:
|
||||
"""Invoke `cli_main()` with `--install` and capture console output.
|
||||
|
||||
`install_extra_command` and `install_extra_recovery_command` share one
|
||||
mock by default (the realistic "both resolve identically" case). Pass
|
||||
`recovery_return` or `recovery_side_effect` to drive the recovery
|
||||
command independently — e.g. to exercise the path where the initial
|
||||
command resolves but the recovery command raises.
|
||||
|
||||
Returns:
|
||||
`(exit_code, perform_mock, console_mock)` — *console_mock* is a
|
||||
`MagicMock` substituted for `deepagents_code.main.console`,
|
||||
@@ -1826,11 +1842,22 @@ class TestInstallExtraSubcommand:
|
||||
perform_mock.side_effect = perform_side_effect
|
||||
else:
|
||||
perform_mock.return_value = perform_return
|
||||
command_mock = MagicMock(
|
||||
return_value=f"uv tool install -U 'deepagents-code[{extra}]'",
|
||||
default_script_cmd = (
|
||||
f"curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS={extra} bash"
|
||||
)
|
||||
command_mock = MagicMock(return_value=command_return or default_script_cmd)
|
||||
if command_side_effect is not None:
|
||||
command_mock.side_effect = command_side_effect
|
||||
# Default: recovery resolves to the same command. Only split into its
|
||||
# own mock when a test drives the recovery command independently.
|
||||
if recovery_return is not None or recovery_side_effect is not None:
|
||||
recovery_mock = MagicMock(
|
||||
return_value=recovery_return or default_script_cmd
|
||||
)
|
||||
if recovery_side_effect is not None:
|
||||
recovery_mock.side_effect = recovery_side_effect
|
||||
else:
|
||||
recovery_mock = command_mock
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch.object(sys, "stdin", mock_stdin),
|
||||
@@ -1848,6 +1875,10 @@ class TestInstallExtraSubcommand:
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
command_mock,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_recovery_command",
|
||||
recovery_mock,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_install_extra",
|
||||
perform_mock,
|
||||
@@ -1874,7 +1905,7 @@ class TestInstallExtraSubcommand:
|
||||
assert "Installed extra 'quickjs'" in text
|
||||
|
||||
def test_failure_renders_log_path_and_manual_command(self) -> None:
|
||||
"""A failed install surfaces both the log path and the manual uv command."""
|
||||
"""A failed install surfaces both the log path and manual script command."""
|
||||
code, _perform, console_mock = self._run_install_capture(
|
||||
"quickjs",
|
||||
perform_return=(False, "resolver: conflict"),
|
||||
@@ -1884,9 +1915,41 @@ class TestInstallExtraSubcommand:
|
||||
assert "Install failed" in text
|
||||
assert "resolver: conflict" in text
|
||||
assert "/tmp/deepagents-install.log" in text
|
||||
assert "uv tool install -U 'deepagents-code" in text
|
||||
assert "curl -LsSf https://langch.in/dcode" in text
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in text
|
||||
assert "quickjs" in text
|
||||
|
||||
def test_failure_escapes_uv_recovery_command_markup(self) -> None:
|
||||
"""Failed uv recovery commands preserve extras rendered by Rich."""
|
||||
command = "uv tool install -U 'deepagents-code[quickjs]'"
|
||||
code, _perform, console_mock = self._run_install_capture(
|
||||
"quickjs",
|
||||
perform_return=(False, "resolver: conflict"),
|
||||
command_return=command,
|
||||
)
|
||||
assert code == 1
|
||||
text = self._printed_text(console_mock)
|
||||
assert "deepagents-code\\[quickjs]" in text
|
||||
|
||||
def test_failure_recovery_command_error_keeps_prior_command(self) -> None:
|
||||
"""A recovery-command error on a failed install keeps the prior command.
|
||||
|
||||
The command resolved before the failure is shown instead of crashing.
|
||||
"""
|
||||
resolved = "uv tool install -U 'deepagents-code[quickjs]'"
|
||||
code, _perform, console_mock = self._run_install_capture(
|
||||
"quickjs",
|
||||
perform_return=(False, "resolver: conflict"),
|
||||
command_return=resolved,
|
||||
recovery_side_effect=ValueError("bad receipt"),
|
||||
)
|
||||
assert code == 1
|
||||
text = self._printed_text(console_mock)
|
||||
assert "Install failed" in text
|
||||
# Falls back to the install_extra_command value resolved before the
|
||||
# failure, with its bracket escaped for Rich.
|
||||
assert "deepagents-code\\[quickjs]" in text
|
||||
|
||||
def test_keyboard_interrupt_exits_130(self) -> None:
|
||||
"""Ctrl-C during install exits 130 with an Aborted message."""
|
||||
code, _perform, console_mock = self._run_install_capture(
|
||||
@@ -1907,7 +1970,8 @@ class TestInstallExtraSubcommand:
|
||||
assert "RuntimeError" in text
|
||||
assert "disk full" in text
|
||||
assert "/tmp/deepagents-install.log" in text
|
||||
assert "uv tool install -U 'deepagents-code" in text
|
||||
assert "curl -LsSf https://langch.in/dcode" in text
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in text
|
||||
assert "quickjs" in text
|
||||
|
||||
def test_command_generation_exception_uses_literal_fallback(self) -> None:
|
||||
@@ -1922,7 +1986,8 @@ class TestInstallExtraSubcommand:
|
||||
assert "RuntimeError" in text
|
||||
assert "metadata broken" in text
|
||||
assert "Run manually: " in text
|
||||
assert "uv tool install -U 'deepagents-code\\[quickjs]'" in text
|
||||
assert "curl -LsSf https://langch.in/dcode" in text
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in text
|
||||
|
||||
def test_interactive_decline_aborts(self) -> None:
|
||||
"""Interactive TTY + reply 'n' to unknown extra aborts with exit 1."""
|
||||
|
||||
@@ -25,6 +25,7 @@ from deepagents_code.update_check import (
|
||||
ShadowedDcode,
|
||||
ToolRequirementIntrospectionError,
|
||||
_extract_release_times,
|
||||
_install_extra_uv_tool_command,
|
||||
_latest_from_releases,
|
||||
_parse_version,
|
||||
_uv_tool_bin_dir,
|
||||
@@ -54,6 +55,7 @@ from deepagents_code.update_check import (
|
||||
get_sdk_release_time,
|
||||
get_seen_version,
|
||||
install_extra_command,
|
||||
install_extra_recovery_command,
|
||||
install_extras_command,
|
||||
install_package_command,
|
||||
is_auto_update_enabled,
|
||||
@@ -2464,48 +2466,120 @@ class TestDependencyRefreshSupported:
|
||||
|
||||
|
||||
class TestInstallExtraCommand:
|
||||
"""`install_extra_command` builds the uv tool install string."""
|
||||
"""`install_extra_command` builds the promoted install-script string."""
|
||||
|
||||
def test_basic(self) -> None:
|
||||
"""Single-quoted bracket form, with `-U` to reinstall."""
|
||||
"""Extras are passed to the install script through its environment."""
|
||||
assert (
|
||||
install_extras_command(["quickjs"])
|
||||
== "uv tool install -U 'deepagents-code[quickjs]'"
|
||||
== "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=quickjs bash"
|
||||
)
|
||||
|
||||
def test_provider_extra(self) -> None:
|
||||
assert (
|
||||
install_extras_command(["fireworks"])
|
||||
== "uv tool install -U 'deepagents-code[fireworks]'"
|
||||
== "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=fireworks bash"
|
||||
)
|
||||
|
||||
def test_installed_extra_names_missing_distribution_returns_empty(self) -> None:
|
||||
"""Display-only introspection stays forgiving when metadata is absent."""
|
||||
assert installed_extra_names("does-not-exist-pkg-xyz-abc") == set()
|
||||
|
||||
def test_install_extra_command_refuses_missing_distribution(self) -> None:
|
||||
"""Reinstall commands must not drop extras when metadata is unavailable."""
|
||||
with pytest.raises(ExtrasIntrospectionError, match="cannot preserve"):
|
||||
def test_install_extra_command_ignores_missing_distribution(self) -> None:
|
||||
"""Display-only commands tolerate missing distribution metadata."""
|
||||
assert (
|
||||
install_extra_command("quickjs", distribution_name="missing-dcode-test")
|
||||
== "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=quickjs bash"
|
||||
)
|
||||
|
||||
def test_no_installed_extras_from_clean_metadata(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Clean metadata with no installed optional deps is distinct from failure."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
"deepagents-code",
|
||||
requires=('definitely-absent-dcode-test-quickjs-xyz; extra == "quickjs"',),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
assert installed_extra_names("deepagents-code") == set()
|
||||
assert (
|
||||
install_extra_command("quickjs", distribution_name="deepagents-code")
|
||||
== "uv tool install -U 'deepagents-code[quickjs]'"
|
||||
install_extra_command("quickjs") == "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=quickjs bash"
|
||||
)
|
||||
|
||||
def test_install_extra_command_refuses_invalid_metadata(
|
||||
def test_install_extra_command_preserves_detected_extras(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Install-script guidance keeps existing extras when metadata reveals them."""
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
"deepagents-code",
|
||||
requires=(
|
||||
'definitely-present-dcode-test-nvidia; extra == "nvidia"',
|
||||
'definitely-absent-dcode-test-quickjs-xyz; extra == "quickjs"',
|
||||
),
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
assert installed_extra_names("deepagents-code") == {"nvidia"}
|
||||
assert (
|
||||
install_extra_command("quickjs") == "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=nvidia,quickjs bash"
|
||||
)
|
||||
|
||||
def test_recovery_command_preserves_uv_receipt_context(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""UV recovery guidance matches the automatic context-preserving install."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
|
||||
python="/opt/Python 3.13/bin/python",
|
||||
)
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
"deepagents-code",
|
||||
requires=('definitely-present-dcode-test-nvidia; extra == "nvidia"',),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.update_check.detect_install_method", lambda: "uv"
|
||||
)
|
||||
|
||||
assert install_extra_recovery_command("quickjs") == (
|
||||
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
|
||||
"'deepagents-code[nvidia,quickjs]' --with langchain-custom"
|
||||
)
|
||||
|
||||
def test_recovery_command_uses_script_for_non_uv_without_receipt(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Unsupported install recovery does not require uv receipt introspection."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }, "bad"')
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.update_check.detect_install_method", lambda: "other"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
lambda _distribution_name="deepagents-code": set(),
|
||||
)
|
||||
|
||||
assert install_extra_recovery_command("quickjs") == (
|
||||
"curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS=quickjs bash"
|
||||
)
|
||||
|
||||
def test_uv_install_extra_command_refuses_invalid_metadata(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Malformed optional-dependency metadata must not drop existing extras."""
|
||||
@@ -2517,10 +2591,15 @@ class TestInstallExtraCommand:
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(ExtrasIntrospectionError, match="Could not parse"):
|
||||
install_extra_command("quickjs", distribution_name="deepagents-code")
|
||||
_install_extra_uv_tool_command(
|
||||
"quickjs", distribution_name="deepagents-code"
|
||||
)
|
||||
|
||||
def test_preserves_installed_extras(self, tmp_path, monkeypatch) -> None:
|
||||
def test_uv_install_extra_command_preserves_installed_extras(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Installing a new extra keeps already-installed extras selected."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
@@ -2530,31 +2609,43 @@ class TestInstallExtraCommand:
|
||||
'definitely-absent-dcode-test-baseten-xyz; extra == "baseten"',
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
assert installed_extra_names("deepagents-code") == {"nvidia"}
|
||||
assert (
|
||||
install_extra_command("baseten", distribution_name="deepagents-code")
|
||||
_install_extra_uv_tool_command(
|
||||
"baseten", distribution_name="deepagents-code"
|
||||
)
|
||||
== "uv tool install -U 'deepagents-code[baseten,nvidia]'"
|
||||
)
|
||||
|
||||
def test_dedupes_existing_extra(self, tmp_path, monkeypatch) -> None:
|
||||
def test_uv_install_extra_command_dedupes_existing_extra(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Installing an already-present extra does not duplicate it."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
"deepagents-code",
|
||||
requires=('definitely-present-dcode-test-nvidia; extra == "nvidia"',),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
assert (
|
||||
install_extra_command("nvidia", distribution_name="deepagents-code")
|
||||
_install_extra_uv_tool_command(
|
||||
"nvidia", distribution_name="deepagents-code"
|
||||
)
|
||||
== "uv tool install -U 'deepagents-code[nvidia]'"
|
||||
)
|
||||
|
||||
def test_drops_composite_extras(self, tmp_path, monkeypatch) -> None:
|
||||
def test_uv_install_extra_command_drops_composite_extras(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Composite extras are not echoed back into uv reinstall commands."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-openai")
|
||||
_write_dist_info(
|
||||
@@ -2565,27 +2656,55 @@ class TestInstallExtraCommand:
|
||||
'definitely-present-dcode-test-openai; extra == "all-providers"',
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
assert installed_extra_names("deepagents-code") == {"nvidia"}
|
||||
assert (
|
||||
install_extra_command("baseten", distribution_name="deepagents-code")
|
||||
_install_extra_uv_tool_command(
|
||||
"baseten", distribution_name="deepagents-code"
|
||||
)
|
||||
== "uv tool install -U 'deepagents-code[baseten,nvidia]'"
|
||||
)
|
||||
|
||||
def test_uv_install_extra_command_preserves_receipt_python_and_with_packages(
|
||||
self, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Installing an extra preserves the uv tool interpreter and `--with` deps."""
|
||||
_write_uv_receipt(
|
||||
tmp_path,
|
||||
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
|
||||
python="/opt/Python 3.13/bin/python",
|
||||
)
|
||||
_write_dist_info(tmp_path, "definitely-present-dcode-test-nvidia")
|
||||
_write_dist_info(
|
||||
tmp_path,
|
||||
"deepagents-code",
|
||||
requires=('definitely-present-dcode-test-nvidia; extra == "nvidia"',),
|
||||
)
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
command = _install_extra_uv_tool_command(
|
||||
"baseten", distribution_name="deepagents-code"
|
||||
)
|
||||
|
||||
assert command == (
|
||||
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
|
||||
"'deepagents-code[baseten,nvidia]' --with langchain-custom"
|
||||
)
|
||||
|
||||
def test_sorts_extras_deterministically(self) -> None:
|
||||
assert (
|
||||
install_extras_command({"quickjs", "baseten", "nvidia"})
|
||||
== "uv tool install -U 'deepagents-code[baseten,nvidia,quickjs]'"
|
||||
== "curl -LsSf https://langch.in/dcode | "
|
||||
"DEEPAGENTS_CODE_EXTRAS=baseten,nvidia,quickjs bash"
|
||||
)
|
||||
|
||||
def test_rejects_shell_metacharacters(self) -> None:
|
||||
assert not is_valid_extra_name("quickjs']; touch /tmp/pwned; '")
|
||||
with pytest.raises(ValueError, match="Invalid extra name"):
|
||||
install_extra_command(
|
||||
"quickjs']; touch /tmp/pwned; '",
|
||||
distribution_name="missing-dcode-test",
|
||||
)
|
||||
install_extra_command("quickjs']; touch /tmp/pwned; '")
|
||||
with pytest.raises(ValueError, match="Invalid extra name"):
|
||||
install_extras_command(["quickjs", "bad;name"])
|
||||
|
||||
@@ -2726,6 +2845,41 @@ class TestPerformInstallExtra:
|
||||
assert success is False
|
||||
assert "Unsupported install method" in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "needle"),
|
||||
[
|
||||
("brew", "Homebrew install detected"),
|
||||
("other", "Unsupported install method detected"),
|
||||
],
|
||||
)
|
||||
async def test_non_uv_install_refuses_before_reading_uv_receipt(
|
||||
self,
|
||||
method: InstallMethod,
|
||||
needle: str,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Unsupported install guidance wins over uv receipt introspection errors."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }, "bad"')
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
# Isolate from the host's installed extras so the script command is
|
||||
# deterministic — install_extra_command merges real distribution
|
||||
# metadata, which would otherwise leak the dev env's extras in.
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
lambda _distribution_name="deepagents-code": set(),
|
||||
)
|
||||
with patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value=method,
|
||||
):
|
||||
success, output = await perform_install_extra("quickjs")
|
||||
assert success is False
|
||||
assert needle in output
|
||||
assert "ToolRequirementIntrospectionError" not in output
|
||||
assert "curl -LsSf https://langch.in/dcode" in output
|
||||
assert "DEEPAGENTS_CODE_EXTRAS=quickjs bash" in output
|
||||
|
||||
async def test_invalid_extra_refuses_before_detecting_install(self) -> None:
|
||||
"""Malformed forced extras must never reach command construction."""
|
||||
with patch(
|
||||
@@ -2751,7 +2905,7 @@ class TestPerformInstallExtra:
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
"deepagents_code.update_check._install_extra_uv_tool_command",
|
||||
return_value="printf 'ok\\n'",
|
||||
),
|
||||
):
|
||||
@@ -2759,6 +2913,29 @@ class TestPerformInstallExtra:
|
||||
assert success is True
|
||||
assert output == "ok"
|
||||
|
||||
async def test_uv_receipt_failure_is_reported(self, tmp_path, monkeypatch) -> None:
|
||||
"""A malformed uv receipt is reported instead of dropping install context."""
|
||||
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }, "bad"')
|
||||
monkeypatch.setattr("sys.prefix", str(tmp_path))
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.shutil.which",
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.extras_info.installed_extra_names",
|
||||
return_value=frozenset(),
|
||||
),
|
||||
):
|
||||
success, output = await perform_install_extra("quickjs")
|
||||
assert success is False
|
||||
assert "ToolRequirementIntrospectionError" in output
|
||||
assert "non-table requirement" in output
|
||||
|
||||
async def test_uv_missing_returns_actionable_error(self) -> None:
|
||||
"""When `uv` is not on PATH, surface a clear error before exec."""
|
||||
with (
|
||||
@@ -2962,7 +3139,7 @@ class TestRunInstallSubprocessFailureModes:
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
"deepagents_code.update_check._install_extra_uv_tool_command",
|
||||
return_value="sleep 5",
|
||||
),
|
||||
):
|
||||
@@ -2987,7 +3164,7 @@ class TestRunInstallSubprocessFailureModes:
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
"deepagents_code.update_check._install_extra_uv_tool_command",
|
||||
return_value="uv tool install -U 'deepagents-code[quickjs]'",
|
||||
),
|
||||
patch("asyncio.create_subprocess_shell", side_effect=_raise),
|
||||
@@ -3010,7 +3187,7 @@ class TestRunInstallSubprocessFailureModes:
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.install_extra_command",
|
||||
"deepagents_code.update_check._install_extra_uv_tool_command",
|
||||
return_value="sh -c 'printf boom 1>&2; exit 1'",
|
||||
),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user