mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 20:50:04 -04:00
771e55f171
Correct misleading recovery hints for users on an editable `uv tool install -e …` setup who try to add an optional extra. The old text told them to `uv sync --extra <name>` from the source directory, which only touches the source `.venv` — never the tool environment that `dcode` actually runs from. Also fixes a Rich markup bug where the literal `[quickjs]` in the hint was parsed as a style tag and stripped from output.
1173 lines
40 KiB
Python
1173 lines
40 KiB
Python
"""Update lifecycle for `deepagents-code`.
|
|
|
|
Handles version checking against PyPI (with caching), install-method detection,
|
|
auto-upgrade execution, config-driven opt-in/out, notification throttling, and
|
|
"what's new" tracking.
|
|
|
|
Most public entry points absorb errors and return sentinel values.
|
|
`set_auto_update` raises on write failures so callers can surface
|
|
actionable feedback.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import operator
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import time
|
|
import tomllib
|
|
from collections.abc import Awaitable, Callable
|
|
from contextlib import suppress
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any, Literal, TextIO
|
|
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
from deepagents_code._version import PYPI_URL, SDK_PYPI_URL, USER_AGENT, __version__
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
|
|
"""On-disk cache of the latest published dcode/SDK versions and SDK release times.
|
|
|
|
Populated by `get_latest_version`; reads short-circuit on the cached payload
|
|
when it is younger than `CACHE_TTL`. SDK upload timestamps are stored under
|
|
`_SDK_RELEASE_TIMES_KEY`.
|
|
"""
|
|
|
|
UPDATE_STATE_FILE: Path = DEFAULT_STATE_DIR / "update_state.json"
|
|
"""Persistent flags for the update-notification UX.
|
|
|
|
Tracks which version the user has been notified about (`notified_version`,
|
|
`notified_at`) and the most recent version they've seen the splash for
|
|
(`seen_version`, `seen_at`). Read by `should_notify_update` and friends
|
|
to suppress repeat notifications across invocations. Auto-update opt-outs
|
|
live in `config.toml`, not here.
|
|
"""
|
|
|
|
CACHE_TTL = 86_400 # 24 hours
|
|
"""Maximum age in seconds before `CACHE_FILE` entries are considered stale.
|
|
|
|
A cached `latest_version.json` younger than this is reused without an HTTP
|
|
call to PyPI; older payloads trigger a fresh fetch. Set conservatively at
|
|
24h since release cadence is on the order of days, not minutes.
|
|
"""
|
|
|
|
INSTALLED_AGE_NOTICE_DAYS = 7
|
|
"""Minimum installed-version age before update notices call it out explicitly."""
|
|
|
|
_SDK_RELEASE_TIMES_KEY = "sdk_release_times"
|
|
"""`CACHE_FILE` key for cached SDK upload timestamps, keyed by version string."""
|
|
|
|
InstallMethod = Literal["uv", "brew", "other", "unknown"]
|
|
|
|
FALLBACK_UPGRADE_COMMAND = "uv tool upgrade deepagents-code"
|
|
"""Generic upgrade hint used when install-method detection fails.
|
|
|
|
Callers that surface an upgrade command in user-facing text should prefer
|
|
`upgrade_command()`; this constant exists so those callers have something
|
|
to render when detection raises unexpectedly. The documented install path
|
|
is `uv tool install` (see `scripts/install.sh`), so the uv command is the
|
|
right display fallback. Execution paths still refuse unrecognized installs
|
|
instead of updating a separate environment.
|
|
"""
|
|
|
|
_UPGRADE_COMMANDS: dict[InstallMethod, str] = {
|
|
"uv": "uv tool upgrade deepagents-code",
|
|
"brew": "brew upgrade deepagents-code",
|
|
}
|
|
"""Upgrade commands keyed by install method.
|
|
|
|
`perform_upgrade` runs only the command matching the detected install method;
|
|
no fallback chain. Unknown non-editable installs are refused rather than
|
|
upgraded with a different package manager, because that can update a separate
|
|
environment from the one currently providing `dcode`.
|
|
"""
|
|
|
|
_UPGRADE_TIMEOUT = 120 # seconds
|
|
"""Wall-clock cap for `perform_upgrade` and `perform_install_extra`."""
|
|
|
|
UPDATE_LOG_DIR: Path = DEFAULT_STATE_DIR / "update_logs"
|
|
"""Directory for persisted update command logs."""
|
|
|
|
UPDATE_LOG_RETENTION_DAYS = 14
|
|
"""Delete update logs older than this many days."""
|
|
|
|
UPDATE_LOG_MAX_FILES = 10
|
|
"""Keep at most this many newest update logs."""
|
|
|
|
UpgradeProgressCallback = Callable[[str], Awaitable[None] | None]
|
|
|
|
|
|
def _parse_version(v: str) -> Version:
|
|
"""Parse a PEP 440 version string into a comparable `Version` object.
|
|
|
|
Supports stable (`1.2.3`) and pre-release (`1.2.3a1`, `1.2.3rc2`) versions.
|
|
|
|
Args:
|
|
v: Version string like `'1.2.3'` or `'1.2.3a1'`.
|
|
|
|
Returns:
|
|
A `packaging.version.Version` instance.
|
|
"""
|
|
return Version(v.strip()) # raises InvalidVersion for non-PEP 440 strings
|
|
|
|
|
|
def _latest_from_releases(
|
|
releases: dict[str, list[object]],
|
|
*,
|
|
include_prereleases: bool,
|
|
) -> str | None:
|
|
"""Pick the newest version from a PyPI `releases` mapping.
|
|
|
|
Skips versions with no uploaded files (empty entries) and, when
|
|
*include_prereleases* is `False`, skips pre-release versions.
|
|
|
|
Args:
|
|
releases: The `releases` dict from the PyPI JSON API.
|
|
include_prereleases: Whether to consider pre-release versions.
|
|
|
|
Returns:
|
|
The highest matching version string, or `None` if none qualify.
|
|
"""
|
|
best: Version | None = None
|
|
best_str: str | None = None
|
|
for ver_str, files in releases.items():
|
|
if not files:
|
|
continue
|
|
try:
|
|
ver = Version(ver_str)
|
|
except InvalidVersion:
|
|
logger.debug("Skipping unparseable release key: %s", ver_str)
|
|
continue
|
|
if not include_prereleases and ver.is_prerelease:
|
|
continue
|
|
if best is None or ver > best:
|
|
best = ver
|
|
best_str = ver_str
|
|
return best_str
|
|
|
|
|
|
def get_latest_version(
|
|
*,
|
|
bypass_cache: bool = False,
|
|
include_prereleases: bool = False,
|
|
) -> str | None:
|
|
"""Fetch the latest deepagents-code version from PyPI, with caching.
|
|
|
|
Results are cached to `CACHE_FILE` to avoid repeated network calls.
|
|
The cache stores both the latest stable and pre-release versions so a
|
|
single PyPI request serves both code paths.
|
|
|
|
Args:
|
|
bypass_cache: Skip the cache and always hit PyPI.
|
|
include_prereleases: When `True`, consider pre-release versions
|
|
(alpha, beta, rc). Stable users should leave this `False`.
|
|
|
|
Returns:
|
|
The latest version string, or `None` on any failure.
|
|
"""
|
|
cache_key = "version_prerelease" if include_prereleases else "version"
|
|
cached_version: str | None = None
|
|
|
|
try:
|
|
if not bypass_cache and CACHE_FILE.exists():
|
|
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
fresh = time.time() - data.get("checked_at", 0) < CACHE_TTL
|
|
if fresh and cache_key in data:
|
|
value = data[cache_key]
|
|
cached_version = value if isinstance(value, str) else None
|
|
release_times = data.get("release_times")
|
|
has_installed_release_time = (
|
|
isinstance(release_times, dict) and __version__ in release_times
|
|
)
|
|
if fresh and cache_key in data and has_installed_release_time:
|
|
return cached_version
|
|
except (OSError, json.JSONDecodeError, TypeError):
|
|
logger.debug("Failed to read update-check cache", exc_info=True)
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
logger.warning(
|
|
"requests package not installed — update checks disabled. "
|
|
"Install with: uv tool install -U deepagents-code --with requests"
|
|
)
|
|
return cached_version
|
|
|
|
try:
|
|
resp = requests.get(
|
|
PYPI_URL,
|
|
headers={"User-Agent": USER_AGENT},
|
|
timeout=3,
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
stable: str = payload["info"]["version"]
|
|
releases: dict[str, list[object]] = payload.get("releases", {})
|
|
if not releases:
|
|
logger.debug("PyPI response missing or empty 'releases' key")
|
|
prerelease = _latest_from_releases(releases, include_prereleases=True)
|
|
except (requests.RequestException, OSError, KeyError, json.JSONDecodeError):
|
|
logger.debug("Failed to fetch latest version from PyPI", exc_info=True)
|
|
return cached_version
|
|
|
|
release_times = _extract_release_times(
|
|
payload, stable=stable, prerelease=prerelease, installed=__version__
|
|
)
|
|
|
|
try:
|
|
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
CACHE_FILE.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": stable,
|
|
"version_prerelease": prerelease,
|
|
"release_times": release_times,
|
|
"checked_at": time.time(),
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
logger.debug("Failed to write update-check cache", exc_info=True)
|
|
|
|
return prerelease if include_prereleases else stable
|
|
|
|
|
|
def _extract_release_times(
|
|
payload: dict[str, Any],
|
|
*,
|
|
stable: str,
|
|
prerelease: str | None,
|
|
installed: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Pull `upload_time_iso_8601` for the given versions out of a PyPI payload.
|
|
|
|
PyPI lists per-file uploads; the first file's timestamp is used as a
|
|
stand-in for the release's publish time (files typically land within
|
|
seconds of each other). Looks up both versions under `releases[ver]`
|
|
rather than `payload["urls"]`, which reflects the project's
|
|
`info.version` and may not match `stable` when the latest on PyPI is
|
|
a pre-release.
|
|
|
|
Args:
|
|
payload: Parsed PyPI JSON response.
|
|
stable: Latest stable version string.
|
|
prerelease: Latest pre-release version string, if any.
|
|
installed: Currently installed version string, if it should be cached.
|
|
|
|
Returns:
|
|
Mapping of version string to ISO-8601 upload time. Silently drops
|
|
versions whose timestamp is missing or malformed.
|
|
"""
|
|
times: dict[str, str] = {}
|
|
releases = payload.get("releases")
|
|
if not isinstance(releases, dict):
|
|
return times
|
|
for ver in (stable, prerelease, installed):
|
|
if not ver:
|
|
continue
|
|
files = releases.get(ver)
|
|
if not isinstance(files, list) or not files:
|
|
continue
|
|
ts = _upload_time(files[0])
|
|
if ts:
|
|
times[ver] = ts
|
|
return times
|
|
|
|
|
|
def _upload_time(file_entry: object) -> str | None:
|
|
"""Return `upload_time_iso_8601` from a PyPI file entry, or `None`."""
|
|
if not isinstance(file_entry, dict):
|
|
return None
|
|
# `isinstance(..., dict)` narrows to `dict[Unknown, Unknown]`, so `.get()`
|
|
# overload resolution is ambiguous. PyPI payloads are str-keyed in practice
|
|
# and the `isinstance(value, str)` check below validates the result anyway.
|
|
value = file_entry.get("upload_time_iso_8601") # type: ignore[call-overload]
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def get_release_time(version: str | None) -> str | None:
|
|
"""Return the cached ISO-8601 upload time for `version`, or `None`.
|
|
|
|
Only versions captured during a prior `get_latest_version` call are
|
|
available; unknown versions, or a `None` input, return `None`.
|
|
"""
|
|
if not version:
|
|
return None
|
|
try:
|
|
if CACHE_FILE.exists():
|
|
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict):
|
|
times = data.get("release_times")
|
|
if isinstance(times, dict):
|
|
value = times.get(version)
|
|
if isinstance(value, str):
|
|
return value
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.debug("Failed to read release_times from cache", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _format_age_from_iso(iso: str | None) -> str:
|
|
"""Return `'released Nd ago'` for an ISO-8601 timestamp, or `""` on failure."""
|
|
if not iso:
|
|
return ""
|
|
from deepagents_code.sessions import format_relative_timestamp
|
|
|
|
age = format_relative_timestamp(iso)
|
|
return f"released {age}" if age else ""
|
|
|
|
|
|
def format_release_age(version: str | None) -> str:
|
|
"""Return a human-readable age for `version` (e.g., `'released 3d ago'`).
|
|
|
|
Returns an empty string when the upload time is unknown (cache entry
|
|
lacks `release_times` for this version, or a `None` version) so callers
|
|
can concatenate unconditionally.
|
|
"""
|
|
return _format_age_from_iso(get_release_time(version))
|
|
|
|
|
|
def format_age_suffix(version: str | None) -> str:
|
|
"""Return `", released Nd ago"` for `version`, or `""` when unknown.
|
|
|
|
The `", "` separator is included so callers can splice the age into a
|
|
parenthetical unconditionally — if the age is unknown, the empty
|
|
string collapses cleanly into the surrounding text.
|
|
"""
|
|
age = format_release_age(version)
|
|
return f", {age}" if age else ""
|
|
|
|
|
|
def format_release_age_parenthetical(version: str | None) -> str:
|
|
"""Return `" (released Nd ago)"` for `version`, or `""` when unknown."""
|
|
age = format_release_age(version)
|
|
return f" ({age})" if age else ""
|
|
|
|
|
|
def _days_old_from_iso(iso: str | None) -> int | None:
|
|
"""Return whole elapsed days for an ISO-8601 timestamp, or `None` on failure."""
|
|
if not iso:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(iso).astimezone()
|
|
except (ValueError, TypeError):
|
|
logger.debug(
|
|
"Failed to parse release timestamp %r for installed age",
|
|
iso,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
days = (datetime.now(tz=dt.tzinfo) - dt).days
|
|
return max(days, 0)
|
|
|
|
|
|
def format_installed_age_suffix(version: str | None) -> str:
|
|
"""Return `" (N days old)"` for installed versions at least a week old."""
|
|
days = _days_old_from_iso(get_release_time(version))
|
|
if days is None or days < INSTALLED_AGE_NOTICE_DAYS:
|
|
return ""
|
|
unit = "day" if days == 1 else "days"
|
|
return f" ({days} {unit} old)"
|
|
|
|
|
|
def get_sdk_release_time(
|
|
version: str | None, *, bypass_cache: bool = False
|
|
) -> str | None:
|
|
"""Return the ISO-8601 upload time for `deepagents` SDK `version`.
|
|
|
|
Reads from `CACHE_FILE` under `sdk_release_times`, falling back to a
|
|
single PyPI fetch on cache miss and writing the result back so
|
|
subsequent calls stay local.
|
|
|
|
Args:
|
|
version: Installed SDK version string.
|
|
bypass_cache: Skip the cache read and always hit PyPI.
|
|
|
|
The result is still written back to the cache.
|
|
|
|
Returns:
|
|
The ISO-8601 upload timestamp, or `None` on any failure (missing
|
|
version, unresolvable on PyPI, `requests` unavailable, or
|
|
network error).
|
|
"""
|
|
if not version:
|
|
return None
|
|
|
|
try:
|
|
if not bypass_cache and CACHE_FILE.exists():
|
|
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict):
|
|
times = data.get(_SDK_RELEASE_TIMES_KEY)
|
|
if isinstance(times, dict):
|
|
cached = times.get(version)
|
|
if isinstance(cached, str):
|
|
return cached
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.debug("Failed to read sdk release_times from cache", exc_info=True)
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
logger.debug("requests unavailable — SDK release time lookup disabled")
|
|
return None
|
|
|
|
try:
|
|
resp = requests.get(
|
|
SDK_PYPI_URL,
|
|
headers={"User-Agent": USER_AGENT},
|
|
timeout=3,
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
releases = payload.get("releases")
|
|
if not isinstance(releases, dict):
|
|
return None
|
|
files = releases.get(version)
|
|
if not isinstance(files, list) or not files:
|
|
return None
|
|
iso = _upload_time(files[0])
|
|
except (requests.RequestException, OSError, json.JSONDecodeError):
|
|
logger.debug("Failed to fetch SDK release time from PyPI", exc_info=True)
|
|
return None
|
|
|
|
if iso:
|
|
_write_sdk_release_time(version, iso)
|
|
return iso
|
|
|
|
|
|
def _write_sdk_release_time(version: str, iso: str) -> None:
|
|
"""Merge a single SDK release timestamp into `CACHE_FILE`.
|
|
|
|
A corrupt existing cache is overwritten rather than propagating the
|
|
decode error — otherwise every caller would keep paying the PyPI
|
|
round-trip because the write never succeeds.
|
|
"""
|
|
data: dict[str, object] = {}
|
|
if CACHE_FILE.exists():
|
|
try:
|
|
raw = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
logger.warning(
|
|
"SDK release-time cache is corrupt; overwriting", exc_info=True
|
|
)
|
|
except OSError:
|
|
logger.debug("Failed to read SDK release-time cache", exc_info=True)
|
|
return
|
|
else:
|
|
if isinstance(raw, dict):
|
|
data = raw
|
|
|
|
times: dict[str, str] = {}
|
|
existing = data.get(_SDK_RELEASE_TIMES_KEY)
|
|
if isinstance(existing, dict):
|
|
times.update(
|
|
{
|
|
k: v
|
|
for k, v in existing.items()
|
|
if isinstance(k, str) and isinstance(v, str)
|
|
}
|
|
)
|
|
times[version] = iso
|
|
data[_SDK_RELEASE_TIMES_KEY] = times
|
|
try:
|
|
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
CACHE_FILE.write_text(json.dumps(data), encoding="utf-8")
|
|
except OSError:
|
|
logger.debug("Failed to write SDK release time to cache", exc_info=True)
|
|
|
|
|
|
def format_sdk_release_age(version: str | None) -> str:
|
|
"""Return a human-readable age for SDK `version` (e.g., `'released 3d ago'`).
|
|
|
|
May trigger a single PyPI fetch on cache miss (3s timeout). Returns an
|
|
empty string on any failure so callers can concatenate unconditionally.
|
|
"""
|
|
return _format_age_from_iso(get_sdk_release_time(version))
|
|
|
|
|
|
def format_sdk_age_suffix(version: str | None) -> str:
|
|
"""Return `", released Nd ago"` for SDK `version`, or `""` when unknown.
|
|
|
|
The `", "` separator is included so callers can splice the age into a
|
|
line unconditionally — if the age is unknown, the empty string
|
|
collapses cleanly into the surrounding text. May trigger a single
|
|
PyPI fetch on cache miss.
|
|
"""
|
|
age = format_sdk_release_age(version)
|
|
return f", {age}" if age else ""
|
|
|
|
|
|
def _read_update_state() -> dict[str, object]:
|
|
"""Read the shared update state file.
|
|
|
|
Returns:
|
|
Parsed dict, or empty dict on missing/corrupt file.
|
|
"""
|
|
try:
|
|
if UPDATE_STATE_FILE.exists():
|
|
raw = json.loads(UPDATE_STATE_FILE.read_text(encoding="utf-8"))
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.debug("Failed to read update state file", exc_info=True)
|
|
return {}
|
|
|
|
|
|
def _write_update_state(
|
|
patch: dict[str, object], *, remove_keys: tuple[str, ...] = ()
|
|
) -> None:
|
|
"""Merge *patch* into the shared update state file and drop *remove_keys*.
|
|
|
|
Args:
|
|
patch: Keys to merge into the existing state.
|
|
remove_keys: Keys to drop from the existing state before writing.
|
|
"""
|
|
data = _read_update_state()
|
|
for key in remove_keys:
|
|
data.pop(key, None)
|
|
data.update(patch)
|
|
try:
|
|
UPDATE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
UPDATE_STATE_FILE.write_text(json.dumps(data), encoding="utf-8")
|
|
except OSError:
|
|
logger.warning(
|
|
"Failed to write update state to %s",
|
|
UPDATE_STATE_FILE,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def should_notify_update(latest: str) -> bool:
|
|
"""Return whether the user should be notified about version *latest*.
|
|
|
|
Throttles notifications to at most once per `CACHE_TTL` period for a
|
|
given version, preventing repeated banners every session.
|
|
|
|
Args:
|
|
latest: The version string to check against.
|
|
|
|
Returns:
|
|
`True` if the user should see the update banner, `False` if the
|
|
notification was already shown within the `CACHE_TTL` window.
|
|
"""
|
|
data = _read_update_state()
|
|
notified_at = data.get("notified_at", 0)
|
|
notified_version = data.get("notified_version")
|
|
return not (
|
|
isinstance(notified_at, (int, float))
|
|
and notified_version == latest
|
|
and time.time() - notified_at < CACHE_TTL
|
|
)
|
|
|
|
|
|
def mark_update_notified(latest: str) -> None:
|
|
"""Record that the user was notified about version *latest*.
|
|
|
|
Writes into the shared update state file so a subsequent
|
|
`should_notify_update` call can suppress duplicate banners.
|
|
|
|
Args:
|
|
latest: The version string that was shown.
|
|
"""
|
|
_write_update_state({"notified_at": time.time(), "notified_version": latest})
|
|
|
|
|
|
def clear_update_notified() -> None:
|
|
"""Clear the "already notified" marker so the update modal re-opens next launch.
|
|
|
|
Removes both `notified_at` and `notified_version` from the shared
|
|
update state file.
|
|
"""
|
|
_write_update_state({}, remove_keys=("notified_at", "notified_version"))
|
|
|
|
|
|
def is_update_available(*, bypass_cache: bool = False) -> tuple[bool, str | None]:
|
|
"""Check whether a newer version of deepagents-code is available.
|
|
|
|
When the installed version is a pre-release (e.g. `0.0.35a1`),
|
|
pre-release versions on PyPI are included in the comparison so alpha
|
|
testers are notified of newer alphas and the eventual stable release.
|
|
Stable installs only compare against stable PyPI releases.
|
|
|
|
Args:
|
|
bypass_cache: Skip the cache and always hit PyPI.
|
|
|
|
Returns:
|
|
A `(available, latest)` tuple.
|
|
|
|
`latest` is the PyPI version string when it was fetched and parsed
|
|
successfully, or `None` when the PyPI check itself fails (network
|
|
error, unparseable response, or non-PEP 440 installed version).
|
|
`available` is `True` only when `latest` is strictly newer than
|
|
the installed version. Callers can therefore distinguish "already
|
|
up to date" (`(False, "1.2.3")`) from "could not reach PyPI"
|
|
(`(False, None)`).
|
|
"""
|
|
try:
|
|
installed = _parse_version(__version__)
|
|
except InvalidVersion:
|
|
logger.warning(
|
|
"Installed version %r is not PEP 440 compliant; "
|
|
"update checks disabled for this install",
|
|
__version__,
|
|
)
|
|
return False, None
|
|
|
|
include_prereleases = installed.is_prerelease
|
|
latest = get_latest_version(
|
|
bypass_cache=bypass_cache,
|
|
include_prereleases=include_prereleases,
|
|
)
|
|
if latest is None:
|
|
return False, None
|
|
|
|
try:
|
|
return _parse_version(latest) > installed, latest
|
|
except InvalidVersion:
|
|
logger.debug("Failed to compare versions", exc_info=True)
|
|
return False, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Install method detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def detect_install_method() -> InstallMethod:
|
|
"""Detect how `deepagents-code` was installed.
|
|
|
|
Checks `sys.prefix` against known paths for uv and Homebrew.
|
|
|
|
Returns:
|
|
The detected install method: `'uv'`, `'brew'`, `'other'`, or `'unknown'`
|
|
(editable/dev installs).
|
|
"""
|
|
from deepagents_code.config import _is_editable_install
|
|
|
|
prefix = sys.prefix
|
|
# uv tool installs live under ~/.local/share/uv/tools/
|
|
if "/uv/tools/" in prefix or "\\uv\\tools\\" in prefix:
|
|
return "uv"
|
|
# Homebrew prefixes
|
|
if any(
|
|
prefix.startswith(p)
|
|
for p in ("/opt/homebrew", "/usr/local/Cellar", "/home/linuxbrew")
|
|
):
|
|
return "brew"
|
|
# Editable / dev installs — don't auto-upgrade
|
|
if _is_editable_install():
|
|
return "unknown"
|
|
return "other"
|
|
|
|
|
|
def upgrade_command(method: InstallMethod | None = None) -> str:
|
|
"""Return the shell command to upgrade `deepagents-code`.
|
|
|
|
Falls back to the documented uv command for display-only guidance.
|
|
|
|
Args:
|
|
method: Install method override.
|
|
|
|
Auto-detected if `None`.
|
|
"""
|
|
if method is None:
|
|
method = detect_install_method()
|
|
return _UPGRADE_COMMANDS.get(method, FALLBACK_UPGRADE_COMMAND)
|
|
|
|
|
|
def cleanup_update_logs(
|
|
*,
|
|
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
|
|
max_files: int = UPDATE_LOG_MAX_FILES,
|
|
) -> None:
|
|
"""Remove old update logs while preserving the newest recent logs.
|
|
|
|
Args:
|
|
retention_days: Maximum age in days to keep.
|
|
max_files: Maximum number of newest log files to keep.
|
|
"""
|
|
try:
|
|
if not UPDATE_LOG_DIR.exists():
|
|
return
|
|
logs = sorted(
|
|
(
|
|
(p, p.stat().st_mtime)
|
|
for p in UPDATE_LOG_DIR.glob("*.log")
|
|
if p.is_file()
|
|
),
|
|
key=operator.itemgetter(1),
|
|
reverse=True,
|
|
)
|
|
cutoff = time.time() - (retention_days * 86_400)
|
|
for idx, (path, mtime) in enumerate(logs):
|
|
if idx >= max_files or mtime < cutoff:
|
|
path.unlink(missing_ok=True)
|
|
except OSError:
|
|
logger.debug("Failed to clean up update logs", exc_info=True)
|
|
|
|
|
|
def create_update_log_path() -> Path:
|
|
"""Return a new timestamped update log path and clean stale logs."""
|
|
cleanup_update_logs()
|
|
stamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")
|
|
return UPDATE_LOG_DIR / f"{stamp}-update.log"
|
|
|
|
|
|
async def _emit_progress(callback: UpgradeProgressCallback | None, line: str) -> None:
|
|
"""Send a progress line to *callback*, supporting sync or async callbacks."""
|
|
if callback is None:
|
|
return
|
|
result = callback(line)
|
|
if isinstance(result, Awaitable):
|
|
await result
|
|
|
|
|
|
async def _read_stream(
|
|
stream: asyncio.StreamReader,
|
|
*,
|
|
lines: list[str],
|
|
log_file: TextIO | None,
|
|
progress: UpgradeProgressCallback | None,
|
|
) -> None:
|
|
"""Read subprocess output, append it to the log file, and emit progress."""
|
|
while True:
|
|
raw = await stream.readline()
|
|
if not raw:
|
|
return
|
|
line = raw.decode(errors="replace").rstrip("\n")
|
|
lines.append(line)
|
|
if log_file is not None:
|
|
with suppress(OSError):
|
|
log_file.write(f"{line}\n")
|
|
log_file.flush()
|
|
await _emit_progress(progress, line)
|
|
|
|
|
|
async def _run_install_subprocess(
|
|
cmd: str,
|
|
*,
|
|
progress: UpgradeProgressCallback | None,
|
|
log_path: Path | None,
|
|
) -> tuple[bool, str]:
|
|
"""Run a shell command, streaming stdout/stderr to *progress* and a log file.
|
|
|
|
Shared subprocess plumbing for `perform_upgrade` and
|
|
`perform_install_extra`. Returns `(success, combined_output)` where
|
|
*combined_output* is the concatenated stdout+stderr, stripped.
|
|
|
|
On timeout or `OSError`, the process is killed and a synthetic error
|
|
line is emitted both to the log and via *progress*. The wall-clock cap
|
|
is `_UPGRADE_TIMEOUT`.
|
|
|
|
Args:
|
|
cmd: Shell command to execute.
|
|
progress: Optional callback invoked for each output line.
|
|
log_path: Optional path to persist command output. Falls back to a
|
|
fresh `create_update_log_path()` when `None`.
|
|
|
|
Returns:
|
|
`(success, output)` — *success* is `True` iff the subprocess exited 0.
|
|
"""
|
|
timeout = _UPGRADE_TIMEOUT
|
|
if log_path is None:
|
|
log_path = create_update_log_path()
|
|
|
|
output_lines: list[str] = []
|
|
proc: asyncio.subprocess.Process | None = None
|
|
log_file: TextIO | None = None
|
|
try:
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_file = log_path.open("w", encoding="utf-8")
|
|
log_file.write(f"$ {cmd}\n")
|
|
log_file.flush()
|
|
except OSError:
|
|
logger.warning(
|
|
"Could not create install log at %s; subprocess output will not be "
|
|
"persisted to disk",
|
|
log_path,
|
|
exc_info=True,
|
|
)
|
|
log_file = None
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await asyncio.wait_for(
|
|
asyncio.gather(
|
|
_read_stream(
|
|
proc.stdout, # type: ignore[arg-type]
|
|
lines=output_lines,
|
|
log_file=log_file,
|
|
progress=progress,
|
|
),
|
|
_read_stream(
|
|
proc.stderr, # type: ignore[arg-type]
|
|
lines=output_lines,
|
|
log_file=log_file,
|
|
progress=progress,
|
|
),
|
|
proc.wait(),
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
except TimeoutError:
|
|
if proc is not None:
|
|
proc.kill()
|
|
await proc.wait()
|
|
msg = f"Command timed out after {timeout}s: {cmd}"
|
|
if log_file is not None:
|
|
with suppress(OSError):
|
|
log_file.write(f"{msg}\n")
|
|
log_file.close()
|
|
await _emit_progress(progress, msg)
|
|
logger.warning(msg)
|
|
return False, msg
|
|
except OSError as exc:
|
|
if log_file is not None:
|
|
with suppress(OSError):
|
|
log_file.close()
|
|
logger.warning("Failed to execute command: %s", cmd, exc_info=True)
|
|
return False, f"Failed to execute: {cmd}\n{type(exc).__name__}: {exc}"
|
|
|
|
if log_file is not None:
|
|
with suppress(OSError):
|
|
log_file.close()
|
|
output = "\n".join(output_lines).strip()
|
|
if proc.returncode == 0:
|
|
return True, output
|
|
logger.warning(
|
|
"Command exited with code %d: %s\n%s",
|
|
proc.returncode,
|
|
cmd,
|
|
output,
|
|
)
|
|
return False, output
|
|
|
|
|
|
async def perform_upgrade(
|
|
*,
|
|
progress: UpgradeProgressCallback | None = None,
|
|
log_path: Path | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""Attempt to upgrade `deepagents-code` using the detected install method.
|
|
|
|
Only tries the detected method — does not fall back to other package
|
|
managers to avoid cross-environment contamination.
|
|
|
|
Args:
|
|
progress: Optional callback invoked for each output line.
|
|
log_path: Optional path to persist command output.
|
|
|
|
Returns:
|
|
`(success, output)` — *output* is the combined stdout/stderr.
|
|
"""
|
|
method = detect_install_method()
|
|
if method == "unknown":
|
|
return False, "Editable install detected — skipping auto-update."
|
|
if method == "other":
|
|
return False, (
|
|
"Unsupported install method detected — cannot auto-update without "
|
|
"knowing which environment provides `dcode`. Reinstall with "
|
|
"`uv tool install -U deepagents-code` or upgrade with the package "
|
|
"manager originally used for this install."
|
|
)
|
|
|
|
cmd = _UPGRADE_COMMANDS.get(method)
|
|
if cmd is None:
|
|
return False, f"No upgrade command for install method: {method}"
|
|
|
|
# Skip brew if binary not on PATH
|
|
if method == "brew" and not shutil.which("brew"):
|
|
return False, "brew not found on PATH."
|
|
|
|
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
|
|
|
|
_EXTRA_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
|
|
"""Conservative package-extra name pattern used before shell command display."""
|
|
|
|
|
|
_PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
|
|
"""Conservative package name pattern used before shell command display."""
|
|
|
|
|
|
def is_valid_extra_name(extra: str) -> bool:
|
|
"""Return whether `extra` is safe to embed in package-extra syntax.
|
|
|
|
Args:
|
|
extra: Candidate extra name from CLI or slash-command input.
|
|
|
|
Returns:
|
|
`True` when the value is a conservative PEP 508-style extra name.
|
|
"""
|
|
return bool(_EXTRA_NAME_RE.fullmatch(extra))
|
|
|
|
|
|
def install_package_command(package: str) -> str:
|
|
"""Return the shell command that adds a package to the dcode tool env.
|
|
|
|
Args:
|
|
package: Package name to install into the existing tool environment.
|
|
|
|
Returns:
|
|
Shell command string suitable for display in error messages.
|
|
|
|
Raises:
|
|
ValueError: If `package` is not a conservative PEP 508-style package
|
|
name.
|
|
"""
|
|
if not _PACKAGE_NAME_RE.fullmatch(package):
|
|
msg = (
|
|
f"Invalid package name {package!r}: must match PEP 508 "
|
|
f"({_PACKAGE_NAME_RE.pattern})"
|
|
)
|
|
raise ValueError(msg)
|
|
return f"uv tool install -U deepagents-code --with {package}"
|
|
|
|
|
|
def install_extra_command(extra: str) -> str:
|
|
"""Return the shell command that adds `extra` to the installed dcode tool.
|
|
|
|
The documented install path is `uv tool install` (see
|
|
`scripts/install.sh`), so the only correct way to add an extra to an
|
|
existing dcode install is to reinstall the tool with the extra
|
|
specified. Single-quoting the bracket form keeps zsh from globbing it.
|
|
|
|
Args:
|
|
extra: The extra name (e.g. `'quickjs'`, `'daytona'`, `'fireworks'`).
|
|
Validated internally against PEP 508 grammar before interpolation
|
|
into the shell command.
|
|
|
|
Returns:
|
|
Shell command string suitable for display in error messages and
|
|
for execution via `perform_install_extra`.
|
|
|
|
Raises:
|
|
ValueError: If `extra` fails PEP 508 validation. Prevents shell
|
|
injection via crafted bracket-escape sequences.
|
|
"""
|
|
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 f"uv tool install -U 'deepagents-code[{extra}]'"
|
|
|
|
|
|
def editable_extra_hint(extra: str) -> str:
|
|
"""Return the canonical action hint for editable installs missing an extra.
|
|
|
|
Shared by every site that detects an editable install and points the user
|
|
at the correct `uv tool install --editable` invocation, so wording stays
|
|
consistent and the literal `[<extra>]` bracket fragment is centrally
|
|
defined (callers that print through Rich markup must still escape it).
|
|
"""
|
|
return (
|
|
"Rerun your `uv tool install --editable` command with "
|
|
f"`--with 'deepagents-code[{extra}]'` added so the extra is "
|
|
"resolved against the editable source."
|
|
)
|
|
|
|
|
|
async def perform_install_extra(
|
|
extra: str,
|
|
*,
|
|
progress: UpgradeProgressCallback | None = None,
|
|
log_path: Path | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""Add `extra` to the installed dcode tool environment.
|
|
|
|
Runs `uv tool install -U 'deepagents-code[<extra>]'`. 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`;
|
|
invalid names are rejected without invoking uv (defense in depth
|
|
against shell injection via the `--force`/`--yes` bypass paths).
|
|
progress: Optional callback invoked for each output line.
|
|
log_path: Optional path to persist command output.
|
|
|
|
Returns:
|
|
`(success, output)` — *output* is the combined stdout/stderr, or an
|
|
explanatory error message when the install method is unsupported
|
|
or `extra` is malformed.
|
|
"""
|
|
if not is_valid_extra_name(extra):
|
|
return False, (
|
|
f"Invalid extra name {extra!r}: must match {_EXTRA_NAME_RE.pattern}"
|
|
)
|
|
method = detect_install_method()
|
|
if method == "unknown":
|
|
return False, (
|
|
"Editable install detected — cannot add extras automatically.\n"
|
|
+ editable_extra_hint(extra)
|
|
)
|
|
if method == "brew":
|
|
# Homebrew formula doesn't expose extras; uv tool install is the
|
|
# 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."
|
|
)
|
|
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."
|
|
)
|
|
|
|
if not shutil.which("uv"):
|
|
return False, (
|
|
"`uv` not found on PATH. Reinstall dcode following the docs, or "
|
|
"install uv (https://docs.astral.sh/uv/) so extras can be added."
|
|
)
|
|
|
|
cmd = install_extra_command(extra)
|
|
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def is_update_check_enabled() -> bool:
|
|
"""Return whether update checks are enabled.
|
|
|
|
Checks `DEEPAGENTS_CODE_NO_UPDATE_CHECK` env var and the `[update].check` key
|
|
in `config.toml`.
|
|
|
|
Defaults to enabled.
|
|
"""
|
|
from deepagents_code._env_vars import NO_UPDATE_CHECK
|
|
|
|
if os.environ.get(NO_UPDATE_CHECK):
|
|
return False
|
|
return _read_update_config().get("check", True)
|
|
|
|
|
|
def is_auto_update_enabled() -> bool:
|
|
"""Return whether auto-update is enabled.
|
|
|
|
Opt-in via `DEEPAGENTS_CODE_AUTO_UPDATE=1` env var or
|
|
`[update].auto_update = true` in `config.toml`.
|
|
|
|
Defaults to `False`.
|
|
|
|
Always disabled for editable installs.
|
|
"""
|
|
from deepagents_code._env_vars import AUTO_UPDATE
|
|
from deepagents_code.config import _is_editable_install
|
|
|
|
if _is_editable_install():
|
|
return False
|
|
if os.environ.get(AUTO_UPDATE, "").lower() in {"1", "true", "yes"}:
|
|
return True
|
|
return _read_update_config().get("auto_update", False)
|
|
|
|
|
|
def set_auto_update(enabled: bool) -> None:
|
|
"""Persist the auto-update preference to `config.toml`.
|
|
|
|
Writes `[update].auto_update` so the setting survives across sessions.
|
|
|
|
Args:
|
|
enabled: Whether auto-update should be enabled.
|
|
"""
|
|
import contextlib
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import tomli_w
|
|
|
|
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
if DEFAULT_CONFIG_PATH.exists():
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
if "update" not in data:
|
|
data["update"] = {}
|
|
data["update"]["auto_update"] = enabled
|
|
|
|
fd, tmp_path = tempfile.mkstemp(dir=DEFAULT_CONFIG_PATH.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
|
|
|
|
def _read_update_config() -> dict[str, bool]:
|
|
"""Read `[update]` section from `config.toml`.
|
|
|
|
Returns:
|
|
A dict of boolean config values, empty on missing/unreadable file.
|
|
"""
|
|
try:
|
|
if not DEFAULT_CONFIG_PATH.exists():
|
|
return {}
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
section = data.get("update", {})
|
|
return {k: v for k, v in section.items() if isinstance(v, bool)}
|
|
except (OSError, tomllib.TOMLDecodeError):
|
|
logger.warning("Could not read [update] config — using defaults", exc_info=True)
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# "What's new" tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_seen_version() -> str | None:
|
|
"""Return the last version the user saw the "what's new" banner for."""
|
|
value = _read_update_state().get("seen_version")
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def mark_version_seen(version: str) -> None:
|
|
"""Record that the user has seen the "what's new" banner for *version*."""
|
|
_write_update_state({"seen_version": version, "seen_at": time.time()})
|
|
|
|
|
|
def should_show_whats_new() -> bool:
|
|
"""Return `True` if this is the first launch on a newer version."""
|
|
seen = get_seen_version()
|
|
if seen is None:
|
|
# First run ever — mark current as seen, don't show banner.
|
|
mark_version_seen(__version__)
|
|
return False
|
|
try:
|
|
return _parse_version(__version__) > _parse_version(seen)
|
|
except InvalidVersion:
|
|
logger.debug("Failed to compare versions for what's-new check", exc_info=True)
|
|
return False
|