fix(code): --reinstall on /install so upgrades rebuild a clean env (#4196)

`/install` now reinstalls the dcode tool cleanly, fixing server-startup
`ImportError`s caused by half-updated environments after adding an extra
or package.

---

When you run `/install <extra>` (e.g. `/install fireworks`) or `/install
<pkg> --package`, dcode runs `uv tool install -U
'deepagents-code[...]'`. The `-U` upgrades the tool **in place**, on top
of the copy that's currently running. An in-place upgrade can leave
stale files behind — for example an old `tools.py` (or its cached
`.pyc`) from the previous version. You then end up with a tool
environment that's a mix of old and new files.

The next time the server starts (after `/restart`), the new
`server_graph.py` tries to import something the stale `tools.py` doesn't
have, and the server dies on startup.

The fix is small: pass `--reinstall` so uv rebuilds the tool environment
from scratch instead of patching it in place. Every file then matches
the freshly resolved version, so adding a model/extra can't leave the
install in a broken state. This is the same thing the manual workaround
(`uv tool install --reinstall -U ...`) does, now baked into the
`/install` flows. The manual-command hints we show users are updated to
match.

Package installs now use the same receipt-preserving reinstall path as
extras, so adding a custom provider package keeps existing extras,
`--with` packages, the uv-managed Python interpreter, and prerelease
channel instead of rebuilding a narrower tool environment.

Made by [Open
SWE](https://openswe.vercel.app/agents/e196cb0c-bde5-c559-1675-3226784d7178)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-24 20:11:36 -04:00
committed by GitHub
parent 26ef927d6c
commit 5e152ac025
7 changed files with 420 additions and 71 deletions
+20 -10
View File
@@ -3461,11 +3461,18 @@ class DeepAgentsApp(App):
)
else:
from deepagents_code.extras_info import ExtrasIntrospectionError
from deepagents_code.update_check import install_package_command
from deepagents_code.update_check import (
ToolRequirementIntrospectionError,
install_package_command,
)
try:
install_cmd = install_package_command(missing.package)
except (ValueError, ExtrasIntrospectionError) as exc:
except (
ValueError,
ExtrasIntrospectionError,
ToolRequirementIntrospectionError,
) as exc:
logger.debug(
"install_package_command failed; falling back to "
"manual hint: %s",
@@ -4313,7 +4320,8 @@ class DeepAgentsApp(App):
"""Handle the `/install <extra>` slash command.
Adds an optional extra (e.g. `quickjs`, `daytona`) to the installed
dcode tool by re-running `uv tool install -U 'deepagents-code[<extra>]'`.
dcode tool by re-running
`uv tool install --reinstall -U 'deepagents-code[<extra>]'`.
Refuses unknown extras unless the user passes a `--force` token.
Args:
@@ -6975,7 +6983,8 @@ class DeepAgentsApp(App):
AppMessage(
"The `langsmith` package is not installed. "
"Install it with "
"`uv tool install -U deepagents-code --with langsmith` "
"`uv tool install --reinstall -U deepagents-code "
"--with langsmith` "
"to enable `/trace`.",
),
)
@@ -11799,7 +11808,7 @@ class DeepAgentsApp(App):
def _ensure_restart_prompt_loaded() -> None:
"""Load the restart-prompt modal before any in-place self-upgrade.
`/install` runs `uv tool install -U 'deepagents-code[...]'`, which
`/install` runs `uv tool install --reinstall -U 'deepagents-code[...]'`, which
rewrites deepagents-code's own on-disk package tree while this process
is running. Modules already in `sys.modules` keep working from memory,
but a *first* import after the rewrite reads the mutated (or
@@ -11836,11 +11845,11 @@ class DeepAgentsApp(App):
redundant hint:
- Owned + idle: show the prompt (its button is the call to action). If
the prompt can't be shown, fall back to a `/restart` hint.
the prompt can't be shown, fall back to a `/restart` hint.
- Owned + busy/connecting: a restart cancels in-flight work, so point
at `/restart` for once the current task finishes.
at `/restart` for once the current task finishes.
- No owned subprocess (remote server): `/restart` can't respawn it, so a
full relaunch is the only way to load the package.
full relaunch is the only way to load the package.
Args:
label: Installed extra/package name, surfaced in the prompt title.
@@ -11866,8 +11875,9 @@ class DeepAgentsApp(App):
try:
from deepagents_code.widgets.restart_prompt import RestartPromptScreen
except ModuleNotFoundError:
# `/install` runs `uv tool install -U 'deepagents-code[...]'`, which
# can rewrite deepagents-code's own on-disk package tree mid-session
# `/install` runs `uv tool install --reinstall -U
# 'deepagents-code[...]'`, which can rewrite deepagents-code's own
# on-disk package tree mid-session
# (see `_ensure_restart_prompt_loaded`). A first import of the modal
# here may then fail with `ModuleNotFoundError`. Degrade to the
# manual `/restart` hint instead of crashing the TUI. The catch is
+9 -2
View File
@@ -3426,11 +3426,18 @@ def _create_model_via_init(
)
else:
from deepagents_code.extras_info import ExtrasIntrospectionError
from deepagents_code.update_check import install_package_command
from deepagents_code.update_check import (
ToolRequirementIntrospectionError,
install_package_command,
)
try:
install_cmd = install_package_command(package)
except (ValueError, ExtrasIntrospectionError) as exc:
except (
ValueError,
ExtrasIntrospectionError,
ToolRequirementIntrospectionError,
) as exc:
logger.debug(
"install_package_command failed; falling back to "
"manual hint: %s",
+1 -1
View File
@@ -2372,7 +2372,7 @@ def cli_main() -> None:
except ImportError as exc:
msg = (
f"ACP dependencies not available: {exc}\n"
"Install with: uv tool install -U deepagents-code "
"Install with: uv tool install --reinstall -U deepagents-code "
"--with deepagents-acp\n"
)
sys.stderr.write(msg)
+77 -41
View File
@@ -291,7 +291,8 @@ def get_latest_version(
except ImportError:
logger.warning(
"requests package not installed — update checks disabled. "
"Install with: uv tool install -U deepagents-code --with requests"
"Install with: uv tool install --reinstall -U deepagents-code "
"--with requests"
)
return cached_version
@@ -1876,6 +1877,8 @@ def _uv_tool_install_command(
include_prereleases: bool | None,
distribution_name: str,
extras_to_add: Iterable[str] = (),
with_packages_to_add: Iterable[str] = (),
reinstall: bool = False,
) -> str:
"""Return the receipt-preserving `uv tool install -U` command.
@@ -1885,6 +1888,19 @@ def _uv_tool_install_command(
`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.
with_packages_to_add: Package names to merge with the receipt's existing
`--with` packages. Names already present (compared canonically) are
not duplicated; genuinely new names are appended after the preserved
ones. Callers must validate these names before passing them — the
builder only `shlex.quote`-s them.
reinstall: When `True`, add `--reinstall` so uv rebuilds the tool
environment from scratch instead of patching it in place. An
in-place `-U` upgrade can leave stale files behind (e.g. an old
`tools.py` or its cached bytecode), which has been observed to
produce a half-updated env that crashes the next server start with
an `ImportError`; the preserved `--python` interpreter and `--with`
packages still apply, so the rebuild keeps the existing tool
context.
Raises:
ExtrasIntrospectionError: If a metadata-sourced extra name fails PEP 508
@@ -1906,12 +1922,17 @@ def _uv_tool_install_command(
except ValueError as exc:
msg = f"Distribution metadata yielded an invalid extra name: {exc}"
raise ExtrasIntrospectionError(msg) from exc
cmd = "uv tool install -U"
cmd = "uv tool install --reinstall -U" if reinstall else "uv tool install -U"
python = _uv_tool_python()
if python is not None:
cmd += f" --python {shlex.quote(python)}"
cmd += f" {requirement}"
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
with_packages = list(_uv_tool_with_packages(distribution_name=distribution_name))
known = {canonicalize_name(package) for package in with_packages}
for package in with_packages_to_add:
if canonicalize_name(package) not in known:
with_packages.append(package)
known.add(canonicalize_name(package))
for package in with_packages:
cmd += f" --with {shlex.quote(package)}"
if _resolve_include_prereleases(include_prereleases):
@@ -2047,30 +2068,42 @@ def install_package_command(
The result is built for *execution* (via `perform_install_package`), not for
display — surfacing raw `uv tool` invocations to the user is intentionally
avoided. `package` is validated and then `shlex.quote`-d: the validation
already blocks shell metacharacters, so the quoting is defense in depth that
keeps the command safe even if the pattern is later loosened.
avoided. `package` is validated here against PEP 508 grammar and then
`shlex.quote`-d by the shared builder: the validation already blocks shell
metacharacters, so the quoting is defense in depth that keeps the command
safe even if the pattern is later loosened.
Already-installed extras are folded into the `deepagents-code[...]`
requirement via the shared `_dcode_extras_requirement` helper, the same way
`install_extras_command` builds its requirement. Without this the reinstall
would replace the tool with a plain `deepagents-code`, silently dropping any
extras the user added through `/install <extra>`.
Delegates to `_uv_tool_install_command` (the same builder the extras path
uses), passing the new package as a `--with` requirement. That builder folds
already-installed extras into the `deepagents-code[...]` requirement, and
preserves the uv-managed Python interpreter, the receipt's existing `--with`
packages, and the installed pre-release channel. Without this, reinstalling
to add a second package would replace the tool with a plain `deepagents-code`
(dropping extras the user added through `/install <extra>`), rebuild with
only the newest `--with` package (dropping previously configured custom
providers), or silently downgrade a pre-release install to the latest stable.
Like the extras path (`_install_extra_uv_tool_command`), passes
`reinstall=True` so the upgrade rebuilds the tool environment cleanly; see
`_uv_tool_install_command`'s `reinstall` parameter for why an in-place
upgrade is unsafe.
Args:
package: Package name to install into the existing tool environment.
distribution_name: Name of the installed distribution to inspect for
already-installed extras.
already-installed extras and uv receipt requirements.
Returns:
Shell command string suitable for execution via the shell.
Raises:
ExtrasIntrospectionError: If installed extras cannot be determined
safely from distribution metadata (refused rather than risk
dropping them).
ValueError: If `package` or any already-installed extra fails PEP 508
validation.
ValueError: If `package` fails PEP 508 validation.
Propagates `ExtrasIntrospectionError` if installed extras cannot be
determined safely from distribution metadata (or a metadata-sourced extra
name fails PEP 508 validation), and `ToolRequirementIntrospectionError` if
the uv tool receipt's interpreter or `--with` packages cannot be determined
safely.
"""
if not _PACKAGE_NAME_RE.fullmatch(package):
msg = (
@@ -2078,19 +2111,14 @@ def install_package_command(
f"({_PACKAGE_NAME_RE.pattern})"
)
raise ValueError(msg)
from deepagents_code.extras_info import (
ExtrasIntrospectionError,
installed_extra_names,
return _uv_tool_install_command(
version=None,
include_prereleases=None,
distribution_name=distribution_name,
with_packages_to_add=(package,),
reinstall=True,
)
try:
extras = installed_extra_names(distribution_name, strict=True)
except ExtrasIntrospectionError as exc:
msg = str(exc)
raise ExtrasIntrospectionError(msg) from exc
requirement = _dcode_extras_requirement(extras)
return f"uv tool install -U {requirement} --with {shlex.quote(package)}"
def install_extras_command(extras: Iterable[str]) -> str:
"""Return the install-script command that installs dcode extras.
@@ -2183,6 +2211,10 @@ def _install_extra_uv_tool_command(
) -> str:
"""Return the receipt-preserving uv command that installs one dcode extra.
Passes `reinstall=True` so the upgrade rebuilds the tool environment from
scratch rather than patching it in place; see `_uv_tool_install_command`'s
`reinstall` parameter for why an in-place upgrade is unsafe.
Args:
extra: The extra name to add. Validated against PEP 508 grammar before
interpolation into the shell command.
@@ -2208,6 +2240,7 @@ def _install_extra_uv_tool_command(
include_prereleases=None,
distribution_name=distribution_name,
extras_to_add=(extra,),
reinstall=True,
)
@@ -2248,11 +2281,11 @@ async def perform_install_extra(
) -> tuple[bool, str]:
"""Add `extra` to the installed dcode tool environment.
Runs `uv tool install -U 'deepagents-code[<extras>]'`, preserving any
extras that are already installed. Editable installs are refused — the
caller should rerun their `uv tool install --editable` command with `--with
'deepagents-code[<extra>]'` added so the extra is resolved against the
editable source.
Runs `uv tool install --reinstall -U 'deepagents-code[<extras>]'`,
preserving any extras that are already installed. Editable installs are
refused — the caller should rerun their `uv tool install --editable` command
with `--with 'deepagents-code[<extra>]'` added so the extra is resolved
against the editable source.
Args:
extra: The extra name to install. Must satisfy `is_valid_extra_name`;
@@ -2319,10 +2352,11 @@ async def perform_install_package(
) -> tuple[bool, str]:
"""Add an arbitrary `package` to the installed dcode tool environment.
Runs `uv tool install -U 'deepagents-code[<extras>]' --with <package>`, the
escape hatch for a provider whose package is not a `deepagents-code` extra
(e.g. a custom or in-house `class_path` model). Already-installed extras are
preserved so the reinstall does not drop them. Editable installs are refused
Runs `uv tool install --reinstall -U 'deepagents-code[<extras>]' --with
<package>`, the escape hatch for a provider whose package is not a
`deepagents-code` extra (e.g. a custom or in-house `class_path` model).
Already-installed extras are preserved so the reinstall does not drop them.
Editable installs are refused
— the caller should rerun their `uv tool install --editable` command with
`--with <package>` added so it resolves against the editable source.
@@ -2378,12 +2412,14 @@ async def perform_install_package(
cmd = install_package_command(package)
except ValueError as exc:
return False, f"{type(exc).__name__}: {exc}"
except ExtrasIntrospectionError as exc:
except (ExtrasIntrospectionError, ToolRequirementIntrospectionError) as exc:
# Distinct from a malformed package name: the running distribution's own
# metadata could not be read or parsed. Leave a breadcrumb so the cause
# is recoverable from logs, even though the user message is unchanged.
# metadata, or the uv tool receipt, could not be read or parsed. Leave a
# breadcrumb so the cause is recoverable from logs, even though the user
# message is unchanged.
logger.warning(
"Could not introspect installed extras for package install of %r",
"Could not introspect installed extras or uv receipt for package "
"install of %r",
package,
exc_info=True,
)
+68 -4
View File
@@ -8819,12 +8819,24 @@ class TestDeferredActions:
assert "/model google_vertexai:<model>" in rendered
async def test_server_failure_missing_unknown_package_shows_uv_command(
self,
self, tmp_path: Path, monkeypatch
) -> None:
"""Manual fallback should include the default uv tool command."""
"""Manual fallback should include the default uv tool command.
`install_package_command` reads the uv tool receipt to preserve the
interpreter and existing `--with` packages, so the receipt must be
isolated to a temporary tool root or the hint degrades to the manual
fallback (see the sibling unreadable-receipt test).
"""
from deepagents_code.model_config import MissingProviderPackageError
from deepagents_code.widgets.messages import ErrorMessage
tmp_path.joinpath("uv-receipt.toml").write_text(
'[tool]\nrequirements = [{ name = "deepagents-code" }]\n',
encoding="utf-8",
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
@@ -8855,8 +8867,60 @@ class TestDeferredActions:
assert isinstance(widget, ErrorMessage)
rendered = str(widget._content)
assert (
"uv tool install -U deepagents-code --with langchain-custom_provider"
in rendered
"uv tool install --reinstall -U deepagents-code "
"--with langchain-custom_provider" in rendered
)
assert "/model custom_provider:<model>" in rendered
async def test_server_failure_unknown_package_unreadable_receipt_manual(
self, tmp_path: Path, monkeypatch
) -> None:
"""An unreadable uv receipt degrades the hint to a manual instruction.
`install_package_command` raises `ToolRequirementIntrospectionError`
when the uv tool receipt is missing a realistic state for a tool whose
receipt records an unsupported `--with` source. The failure-render path
must catch it and surface an actionable manual hint rather than crash
while building the recovery message.
"""
from deepagents_code.model_config import MissingProviderPackageError
from deepagents_code.widgets.messages import ErrorMessage
# tmp_path intentionally has no uv-receipt.toml, so the receipt read
# raises ToolRequirementIntrospectionError.
monkeypatch.setattr("sys.prefix", str(tmp_path))
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {"model_name": "custom_provider:fake"}
app._connecting = True
error = MissingProviderPackageError(
"Missing package for provider 'custom_provider'.",
provider="custom_provider",
package="langchain-custom_provider",
)
with (
patch(
"deepagents_code.extras_info.extra_for_package",
return_value=None,
),
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=set(),
),
):
app.on_deep_agents_app_server_start_failed(
DeepAgentsApp.ServerStartFailed(error=error)
)
await pilot.pause()
widget = app._startup_failure_widget
assert isinstance(widget, ErrorMessage)
rendered = str(widget._content)
assert (
"install the `langchain-custom_provider` package manually" in rendered
)
assert "/model custom_provider:<model>" in rendered
+42 -3
View File
@@ -3905,8 +3905,20 @@ class TestCreateModelViaInitImportError:
_create_model_via_init("nemotron", "nvidia", {})
@patch("langchain.chat_models.init_chat_model")
def test_unknown_provider_fallback_package_name(self, mock_init: Mock) -> None:
"""Unknown provider falls back to langchain-{provider} package name."""
def test_unknown_provider_fallback_package_name(
self, mock_init: Mock, tmp_path, monkeypatch
) -> None:
"""Unknown provider falls back to langchain-{provider} package name.
`install_package_command` reads the uv tool receipt, so it is isolated to
a temporary tool root here; otherwise the hint degrades to the manual
fallback (see the sibling unreadable-receipt test).
"""
tmp_path.joinpath("uv-receipt.toml").write_text(
'[tool]\nrequirements = [{ name = "deepagents-code" }]\n',
encoding="utf-8",
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
mock_init.side_effect = ImportError("no module")
with (
patch("importlib.util.find_spec", return_value=None),
@@ -3917,13 +3929,40 @@ class TestCreateModelViaInitImportError:
pytest.raises(
ModelConfigError,
match=(
"Install with: uv tool install -U deepagents-code "
"Install with: uv tool install --reinstall -U deepagents-code "
"--with langchain-custom_provider"
),
),
):
_create_model_via_init("some-model", "custom_provider", {})
@patch("langchain.chat_models.init_chat_model")
def test_unknown_provider_receipt_failure_falls_back_to_manual(
self, mock_init: Mock, tmp_path, monkeypatch
) -> None:
"""An unreadable uv receipt degrades to the manual-install hint.
Exercises the `ToolRequirementIntrospectionError` arm of the fallback:
`install_package_command` reads the uv tool receipt, and a missing
receipt must surface an actionable message instead of letting the error
leak out of hint construction.
"""
# tmp_path has no uv-receipt.toml, so the receipt read raises.
monkeypatch.setattr("sys.prefix", str(tmp_path))
mock_init.side_effect = ImportError("no module")
with (
patch("importlib.util.find_spec", return_value=None),
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=set(),
),
pytest.raises(
ModelConfigError,
match="Install the 'langchain-custom_provider' package manually",
),
):
_create_model_via_init("some-model", "custom_provider", {})
@patch("langchain.chat_models.init_chat_model")
def test_unknown_provider_introspection_failure_falls_back_to_manual(
self, mock_init: Mock
+203 -10
View File
@@ -2558,7 +2558,7 @@ class TestInstallExtraCommand:
)
assert install_extra_recovery_command("quickjs") == (
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
"uv tool install --reinstall -U --python '/opt/Python 3.13/bin/python' "
"'deepagents-code[nvidia,quickjs]' --with langchain-custom"
)
@@ -2618,7 +2618,7 @@ class TestInstallExtraCommand:
_install_extra_uv_tool_command(
"baseten", distribution_name="deepagents-code"
)
== "uv tool install -U 'deepagents-code[baseten,nvidia]'"
== "uv tool install --reinstall -U 'deepagents-code[baseten,nvidia]'"
)
def test_uv_install_extra_command_dedupes_existing_extra(
@@ -2639,7 +2639,7 @@ class TestInstallExtraCommand:
_install_extra_uv_tool_command(
"nvidia", distribution_name="deepagents-code"
)
== "uv tool install -U 'deepagents-code[nvidia]'"
== "uv tool install --reinstall -U 'deepagents-code[nvidia]'"
)
def test_uv_install_extra_command_drops_composite_extras(
@@ -2665,7 +2665,7 @@ class TestInstallExtraCommand:
_install_extra_uv_tool_command(
"baseten", distribution_name="deepagents-code"
)
== "uv tool install -U 'deepagents-code[baseten,nvidia]'"
== "uv tool install --reinstall -U 'deepagents-code[baseten,nvidia]'"
)
def test_uv_install_extra_command_preserves_receipt_python_and_with_packages(
@@ -2691,7 +2691,7 @@ class TestInstallExtraCommand:
)
assert command == (
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
"uv tool install --reinstall -U --python '/opt/Python 3.13/bin/python' "
"'deepagents-code[baseten,nvidia]' --with langchain-custom"
)
@@ -2730,37 +2730,43 @@ class TestInstallPackageCommand:
def test_basic_no_extras(self, tmp_path, monkeypatch) -> None:
"""Clean metadata with no installed extras yields a plain requirement."""
_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 (
install_package_command(
"langchain-custom", distribution_name="deepagents-code"
)
== "uv tool install -U deepagents-code --with langchain-custom"
== "uv tool install --reinstall -U deepagents-code --with langchain-custom"
)
def test_allows_pep508_name_separators(self, tmp_path, monkeypatch) -> None:
_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 (
install_package_command(
"langchain.custom_provider", distribution_name="deepagents-code"
)
== "uv tool install -U deepagents-code --with langchain.custom_provider"
== "uv tool install --reinstall -U deepagents-code "
"--with langchain.custom_provider"
)
def test_preserves_installed_extras(self, tmp_path, monkeypatch) -> None:
"""Adding a package 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,
@@ -2770,6 +2776,7 @@ class TestInstallPackageCommand:
'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"}
@@ -2777,9 +2784,158 @@ class TestInstallPackageCommand:
install_package_command(
"langchain-custom", distribution_name="deepagents-code"
)
== "uv tool install -U 'deepagents-code[nvidia]' --with langchain-custom"
== "uv tool install --reinstall -U 'deepagents-code[nvidia]' "
"--with langchain-custom"
)
def test_preserves_receipt_python_and_with_packages(
self, tmp_path, monkeypatch
) -> None:
"""Adding a package keeps uv receipt interpreter and `--with` packages."""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, { name = "langchain-first" }',
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_package_command(
"langchain-second", distribution_name="deepagents-code"
)
assert command == (
"uv tool install --reinstall -U --python '/opt/Python 3.13/bin/python' "
"'deepagents-code[nvidia]' --with langchain-first "
"--with langchain-second"
)
def test_does_not_duplicate_existing_receipt_package(
self, tmp_path, monkeypatch
) -> None:
"""Reinstalling an existing package does not emit duplicate `--with` args."""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
)
_write_dist_info(tmp_path, "deepagents-code")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.syspath_prepend(str(tmp_path))
command = install_package_command(
"LangChain_Custom", distribution_name="deepagents-code"
)
assert (
command
== "uv tool install --reinstall -U deepagents-code --with langchain-custom"
)
def test_preserves_prerelease_channel(self, tmp_path, monkeypatch) -> None:
"""Adding a package to a pre-release install keeps the pre-release channel.
The `--reinstall` rebuild re-resolves the unpinned `deepagents-code`
requirement from scratch; without `--prerelease allow` uv would resolve
to the latest stable and silently downgrade a pre-release user.
"""
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
_write_dist_info(tmp_path, "deepagents-code")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.syspath_prepend(str(tmp_path))
with patch("deepagents_code.update_check.__version__", "1.0.0a1"):
command = install_package_command(
"langchain-custom", distribution_name="deepagents-code"
)
assert command == (
"uv tool install --reinstall -U deepagents-code "
"--with langchain-custom --prerelease allow"
)
def test_stable_channel_omits_prerelease_flag(self, tmp_path, monkeypatch) -> None:
"""A stable install does not add `--prerelease allow` for a package add.
The negative companion to `test_preserves_prerelease_channel`: pins that
the channel is *inferred* from the installed version, so an inverted
condition that always emitted `--prerelease allow` would fail here.
"""
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
_write_dist_info(tmp_path, "deepagents-code")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.syspath_prepend(str(tmp_path))
with patch("deepagents_code.update_check.__version__", "1.0.0"):
command = install_package_command(
"langchain-custom", distribution_name="deepagents-code"
)
assert command == (
"uv tool install --reinstall -U deepagents-code --with langchain-custom"
)
def test_appends_new_with_package_after_sorted_receipt_packages(
self, tmp_path, monkeypatch
) -> None:
"""A new `--with` package is appended after preserved ones, not re-sorted.
Preserved receipt packages come back sorted, and the new package is
appended afterward regardless of where it would sort. Using a new name
(`langchain-alpha`) that sorts *before* the receipt's (`langchain-zeta`)
distinguishes this append-after-preserved contract from a plain
alphabetical sort of the union, which the same-order cases cannot.
"""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, { name = "langchain-zeta" }',
)
_write_dist_info(tmp_path, "deepagents-code")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.syspath_prepend(str(tmp_path))
command = install_package_command(
"langchain-alpha", distribution_name="deepagents-code"
)
assert command == (
"uv tool install --reinstall -U deepagents-code "
"--with langchain-zeta --with langchain-alpha"
)
def test_unpreservable_receipt_with_requirement_raises(
self, tmp_path, monkeypatch
) -> None:
"""A `--with` requirement uv can't re-express by name aborts the build.
Exercises the `unsupported_keys` arm through `install_package_command`'s
newly-added receipt read: a source-pinned `--with` entry (e.g. an
editable install) carries keys beyond `name`, so it cannot be safely
re-expressed as a `--with <name>` and must raise rather than be silently
dropped from the rebuilt command.
"""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, '
'{ name = "langchain-custom", editable = true }',
)
_write_dist_info(tmp_path, "deepagents-code")
monkeypatch.setattr("sys.prefix", str(tmp_path))
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(
ToolRequirementIntrospectionError,
match="cannot be preserved automatically",
):
install_package_command(
"langchain-new", distribution_name="deepagents-code"
)
def test_refuses_missing_distribution(self) -> None:
"""Reinstalls must not drop extras when metadata is unavailable."""
with pytest.raises(ExtrasIntrospectionError, match="cannot preserve"):
@@ -2968,7 +3124,8 @@ class TestIsValidPackageName:
def test_rejects_option_injection_leading_dash(self) -> None:
"""A leading dash would smuggle uv options into `--with <name>`.
The command is `uv tool install -U deepagents-code --with <name>`; a name
The command is `uv tool install --reinstall -U deepagents-code --with
<name>`; a name
like `-rreqs.txt` or `--editable` would be parsed by uv as a flag, not a
package. The validator must reject these regardless of `--force`/`--yes`.
"""
@@ -3118,6 +3275,40 @@ class TestPerformInstallPackage:
assert "metadata unreadable" in output
assert "introspect installed extras" in caplog.text
async def test_uv_receipt_failure_is_reported_and_logged(
self, tmp_path, monkeypatch, caplog
) -> None:
"""An unreadable uv receipt surfaces as a reported, logged error.
`install_package_command` now reads the uv tool receipt to preserve the
interpreter and `--with` packages, so a malformed receipt raises
`ToolRequirementIntrospectionError`. The executor must report it rather
than let it escape unhandled — narrowing back to `except
ExtrasIntrospectionError` would let the error crash the caller.
"""
_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(),
),
caplog.at_level(logging.WARNING, logger="deepagents_code.update_check"),
):
success, output = await perform_install_package("langchain-custom")
assert success is False
assert "ToolRequirementIntrospectionError" in output
assert "non-table requirement" in output
assert "uv receipt" in caplog.text
class TestRunInstallSubprocessFailureModes:
"""Failure-mode coverage routed through `perform_install_extra`.
@@ -3166,7 +3357,9 @@ class TestRunInstallSubprocessFailureModes:
),
patch(
"deepagents_code.update_check._install_extra_uv_tool_command",
return_value="uv tool install -U 'deepagents-code[quickjs]'",
return_value=(
"uv tool install --reinstall -U 'deepagents-code[quickjs]'"
),
),
patch("asyncio.create_subprocess_shell", side_effect=_raise),
):