mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): report editable SDK runtime version (#4304)
Editable SDK installs can leave package metadata behind when the local
source version changes. Version reporting now detects when `deepagents`
itself is installed editable and reads the imported SDK’s runtime
`__version__`, while keeping metadata-based behavior for normal installs
and fallback cases.
## Changes
- Teach `resolve_sdk_version()` to detect editable `deepagents` installs
via `direct_url.json` and prefer runtime `deepagents.__version__` when
available.
- Keep normal package installs on
`importlib.metadata.version("deepagents")`, and degrade back to metadata
when editable metadata is malformed or the runtime version cannot be
read.
- Cover stale editable metadata, non-editable metadata, malformed
editable metadata, import failures, and unusable runtime versions in
`TestResolveSdkVersion`.
This commit is contained in:
@@ -7,7 +7,9 @@ in either plain text (for stdout) or markdown (for rich UI contexts).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -16,7 +18,10 @@ from importlib.metadata import (
|
||||
distribution,
|
||||
version as pkg_version,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
@@ -32,20 +37,106 @@ that don't care which kind of failure happened can treat both the same.
|
||||
"""
|
||||
|
||||
|
||||
def _editable_sdk_source_root() -> Path | None:
|
||||
"""Return the editable `deepagents` source root from package metadata."""
|
||||
try:
|
||||
raw = distribution("deepagents").read_text("direct_url.json")
|
||||
if not raw:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
logger.debug("Ignoring malformed deepagents direct_url.json metadata")
|
||||
return None
|
||||
dir_info = data.get("dir_info")
|
||||
if not isinstance(dir_info, dict):
|
||||
logger.debug("Ignoring malformed deepagents direct_url.json dir_info")
|
||||
return None
|
||||
if not dir_info.get("editable", False):
|
||||
return None
|
||||
url = data.get("url")
|
||||
if not isinstance(url, str):
|
||||
logger.debug("Ignoring editable deepagents metadata without a source URL")
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "file":
|
||||
logger.debug("Ignoring editable deepagents metadata with non-file URL")
|
||||
return None
|
||||
path = url2pathname(parsed.path)
|
||||
if parsed.netloc and parsed.netloc != "localhost":
|
||||
path = f"//{parsed.netloc}{path}"
|
||||
return Path(path)
|
||||
except (PackageNotFoundError, OSError, ValueError, TypeError):
|
||||
# `OSError` covers `FileNotFoundError`/`PermissionError`/etc. while
|
||||
# reading the metadata file; `ValueError` covers malformed JSON
|
||||
# (`json.JSONDecodeError`), bad encodings (`UnicodeDecodeError`), and an
|
||||
# invalid IPv6 host from `urlparse`; `TypeError` covers a non-text
|
||||
# `read_text` payload. `url2pathname` is intentionally lenient and adds
|
||||
# no new failure modes. This probe must never propagate, since callers
|
||||
# treat it as a best-effort refinement over the metadata version.
|
||||
return None
|
||||
|
||||
|
||||
def _sdk_version_from_source(root: Path) -> str | None:
|
||||
"""Read `deepagents.__version__` from a source tree rooted at `root`.
|
||||
|
||||
Returns:
|
||||
The source SDK version, or `None` when it cannot be read.
|
||||
"""
|
||||
version_file = root / "deepagents" / "_version.py"
|
||||
try:
|
||||
source = version_file.read_text(encoding="utf-8")
|
||||
module = ast.parse(source, filename=str(version_file))
|
||||
except (OSError, SyntaxError, ValueError):
|
||||
# Reached only for editable installs, where the package is known to be
|
||||
# present — so an unreadable or malformed version file is a broken local
|
||||
# checkout, not an absent dependency. Warn (not debug): the source
|
||||
# version is masked and the caller falls back to potentially stale
|
||||
# metadata.
|
||||
logger.warning("Failed to read deepagents SDK version file", exc_info=True)
|
||||
return None
|
||||
for node in module.body:
|
||||
# Match only a plain `__version__ = "..."` assignment. release-please
|
||||
# writes the SDK's `_version.py` that way, so annotated (`ast.AnnAssign`)
|
||||
# or tuple-target forms are intentionally ignored.
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(
|
||||
isinstance(target, ast.Name) and target.id == "__version__"
|
||||
for target in node.targets
|
||||
):
|
||||
continue
|
||||
try:
|
||||
value = ast.literal_eval(node.value)
|
||||
except (ValueError, TypeError):
|
||||
# A non-literal `__version__` RHS masks the source version just like
|
||||
# an unreadable file, so warn for parity with the read/parse failure
|
||||
# above rather than falling back to stale metadata silently.
|
||||
logger.warning(
|
||||
"Failed to evaluate deepagents SDK __version__ literal",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return value if isinstance(value, str) and value else None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sdk_version() -> tuple[str | None, SdkVersionStatus]:
|
||||
"""Resolve the installed `deepagents` SDK version from package metadata.
|
||||
"""Resolve the installed `deepagents` SDK version.
|
||||
|
||||
Single source of truth for the lookup that `--version`, `/version`, and
|
||||
`doctor` each used to reimplement. Distinguishes a genuinely missing
|
||||
package from an unexpected metadata error so diagnostic callers can report
|
||||
the two differently, while collapse-friendly callers can ignore the split.
|
||||
`doctor` each used to reimplement. Editable installs can have stale package
|
||||
metadata after local version files change, so they prefer the source tree's
|
||||
`_version.py` and fall back to metadata when the source version is
|
||||
unavailable. Distinguishes a genuinely missing package from an unexpected
|
||||
metadata error so diagnostic callers can report the two differently, while
|
||||
collapse-friendly callers can ignore the split.
|
||||
|
||||
Returns:
|
||||
`(version, status)`. `version` is the resolved version string when
|
||||
`status` is `"resolved"`, otherwise `None`.
|
||||
"""
|
||||
try:
|
||||
return pkg_version("deepagents"), "resolved"
|
||||
metadata_version = pkg_version("deepagents")
|
||||
except PackageNotFoundError:
|
||||
logger.debug("deepagents SDK package not found in environment")
|
||||
return None, "not_installed"
|
||||
@@ -55,6 +146,14 @@ def resolve_sdk_version() -> tuple[str | None, SdkVersionStatus]:
|
||||
)
|
||||
return None, "error"
|
||||
|
||||
source_root = _editable_sdk_source_root()
|
||||
if source_root:
|
||||
source_version = _sdk_version_from_source(source_root)
|
||||
if source_version:
|
||||
return source_version, "resolved"
|
||||
|
||||
return metadata_version, "resolved"
|
||||
|
||||
|
||||
_EXTRA_MARKER_RE = re.compile(r"""extra\s*==\s*["']([^"']+)["']""")
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from deepagents_code.extras_info import (
|
||||
MODEL_PROVIDER_EXTRAS,
|
||||
SANDBOX_EXTRAS,
|
||||
STANDALONE_EXTRAS,
|
||||
_editable_sdk_source_root,
|
||||
extra_for_package,
|
||||
format_extras_status,
|
||||
format_extras_status_plain,
|
||||
@@ -361,15 +362,226 @@ def test_format_extras_status_renders_markdown_table() -> None:
|
||||
class TestResolveSdkVersion:
|
||||
"""Tests for the shared `deepagents` SDK version resolver."""
|
||||
|
||||
def test_resolved_returns_version(self) -> None:
|
||||
"""A successful lookup reports the version and `resolved` status."""
|
||||
with patch(
|
||||
"deepagents_code.extras_info.pkg_version", return_value="1.2.3"
|
||||
) as mock:
|
||||
def test_resolved_returns_metadata_version_for_normal_install(self) -> None:
|
||||
"""A normal install reports the package metadata version."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = None
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.extras_info.pkg_version", return_value="1.2.3"
|
||||
) as mock,
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
mock.assert_called_once_with("deepagents")
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
def test_resolved_prefers_source_version_for_editable_install(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An editable SDK reports `_version.py` over stale metadata."""
|
||||
version_file = tmp_path / "deepagents" / "_version.py"
|
||||
version_file.parent.mkdir()
|
||||
version_file.write_text('__version__ = "1.2.4"\n', encoding="utf-8")
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.4", "resolved")
|
||||
|
||||
def test_resolved_falls_back_to_metadata_when_editable_version_file_missing(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An editable SDK still reports metadata if `_version.py` is unavailable."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"direct_url",
|
||||
[
|
||||
"[]", # valid JSON, wrong top-level type
|
||||
'{"dir_info": null}', # valid JSON, dir_info not an object
|
||||
'{"url": "file:///repo", "dir_info": {"editable": false}}', # non-editable
|
||||
'{"url": "file:///repo", "dir_info": {}}', # editable key absent
|
||||
],
|
||||
)
|
||||
def test_resolved_uses_metadata_when_not_an_editable_install(
|
||||
self, direct_url: str
|
||||
) -> None:
|
||||
"""Non-editable or unexpectedly-shaped metadata never prefers source."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = direct_url
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
@pytest.mark.parametrize("side_effect", [ValueError, OSError, TypeError])
|
||||
def test_resolved_uses_metadata_when_direct_url_read_fails(
|
||||
self, side_effect: type[Exception]
|
||||
) -> None:
|
||||
"""A failed/invalid `direct_url.json` read degrades to the metadata version.
|
||||
|
||||
Exercises the `_editable_sdk_source_root` except arm: invalid JSON
|
||||
(`ValueError`), an unreadable metadata file (`OSError`), and a
|
||||
non-text payload (`TypeError`) must all be swallowed rather than
|
||||
crashing the resolver.
|
||||
"""
|
||||
dist = MagicMock()
|
||||
dist.read_text.side_effect = side_effect("boom")
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
def test_resolved_falls_back_to_metadata_when_editable_version_file_invalid(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A broken editable SDK version file degrades to the metadata version."""
|
||||
version_file = tmp_path / "deepagents" / "_version.py"
|
||||
version_file.parent.mkdir()
|
||||
version_file.write_text("__version__ = ", encoding="utf-8")
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
@pytest.mark.parametrize("source_version", ["", None, 123])
|
||||
def test_resolved_falls_back_to_metadata_when_source_version_unusable(
|
||||
self, tmp_path: Path, source_version: object
|
||||
) -> None:
|
||||
"""An empty or non-string source `__version__` is rejected for metadata."""
|
||||
version_file = tmp_path / "deepagents" / "_version.py"
|
||||
version_file.parent.mkdir()
|
||||
version_file.write_text(f"__version__ = {source_version!r}\n", encoding="utf-8")
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
def test_resolved_uses_metadata_for_editable_non_file_url(self) -> None:
|
||||
"""An editable install with a non-`file` source URL prefers metadata."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
'{"url":"https://example.com/repo","dir_info":{"editable":true}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"direct_url",
|
||||
[
|
||||
'{"dir_info":{"editable":true}}', # url key absent
|
||||
'{"url":123,"dir_info":{"editable":true}}', # url not a string
|
||||
],
|
||||
)
|
||||
def test_resolved_uses_metadata_when_editable_url_unusable(
|
||||
self, direct_url: str
|
||||
) -> None:
|
||||
"""An editable install without a usable source URL prefers metadata."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = direct_url
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
def test_resolved_falls_back_to_metadata_when_version_assignment_absent(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A valid `_version.py` with no `__version__` assignment uses metadata."""
|
||||
version_file = tmp_path / "deepagents" / "_version.py"
|
||||
version_file.parent.mkdir()
|
||||
version_file.write_text('VERSION = "1.2.4"\n', encoding="utf-8")
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
def test_resolved_falls_back_to_metadata_when_version_is_non_literal(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A non-literal `__version__` RHS is rejected in favor of metadata.
|
||||
|
||||
Exercises the `ast.literal_eval` except arm — distinct from a
|
||||
`SyntaxError` at parse time — where a syntactically valid but
|
||||
dynamically-computed assignment cannot be read as a constant.
|
||||
"""
|
||||
version_file = tmp_path / "deepagents" / "_version.py"
|
||||
version_file.parent.mkdir()
|
||||
version_file.write_text("__version__ = _compute_version()\n", encoding="utf-8")
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{tmp_path.as_uri()}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.extras_info.pkg_version", return_value="1.2.3"),
|
||||
patch("deepagents_code.extras_info.distribution", return_value=dist),
|
||||
):
|
||||
version, status = resolve_sdk_version()
|
||||
assert (version, status) == ("1.2.3", "resolved")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
# UNC authority is folded back into the path...
|
||||
("file://server/share/proj", Path("//server/share/proj")),
|
||||
# ...but the conventional `localhost` authority is dropped.
|
||||
("file://localhost/repo", Path("/repo")),
|
||||
],
|
||||
)
|
||||
def test_editable_source_root_handles_url_authority(
|
||||
self, url: str, expected: Path
|
||||
) -> None:
|
||||
"""`file://` authority handling distinguishes UNC hosts from `localhost`."""
|
||||
dist = MagicMock()
|
||||
dist.read_text.return_value = (
|
||||
f'{{"url":"{url}","dir_info":{{"editable":true}}}}'
|
||||
)
|
||||
with patch("deepagents_code.extras_info.distribution", return_value=dist):
|
||||
assert _editable_sdk_source_root() == expected
|
||||
|
||||
def test_not_installed_distinguished_from_error(self) -> None:
|
||||
"""A missing package reports `not_installed`, never `error`."""
|
||||
with patch(
|
||||
|
||||
Reference in New Issue
Block a user