zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,3124 @@
|
|
|
1
|
+
"""Update lifecycle for `deepagents-code`.
|
|
2
|
+
|
|
3
|
+
Handles version checking against PyPI (with caching), install-method detection,
|
|
4
|
+
auto-upgrade execution, config-driven opt-in/out, notification throttling, and
|
|
5
|
+
"what's new" tracking.
|
|
6
|
+
|
|
7
|
+
Most public entry points absorb errors and return sentinel values.
|
|
8
|
+
`set_auto_update` raises on write failures so callers can surface
|
|
9
|
+
actionable feedback.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
import operator
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import tomllib
|
|
28
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Literal, NamedTuple, TextIO
|
|
34
|
+
|
|
35
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
36
|
+
from packaging.utils import canonicalize_name
|
|
37
|
+
from packaging.version import InvalidVersion, Version
|
|
38
|
+
|
|
39
|
+
from deepagents_code._version import (
|
|
40
|
+
DISTRIBUTION_NAME,
|
|
41
|
+
PYPI_URL,
|
|
42
|
+
SDK_PYPI_URL,
|
|
43
|
+
USER_AGENT,
|
|
44
|
+
__version__,
|
|
45
|
+
)
|
|
46
|
+
from deepagents_code.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
|
|
51
|
+
"""On-disk cache of the latest published dcode/SDK versions and SDK release times.
|
|
52
|
+
|
|
53
|
+
Populated by `get_latest_version`; reads short-circuit on the cached payload
|
|
54
|
+
when it is younger than `CACHE_TTL`. SDK upload timestamps are stored under
|
|
55
|
+
`_SDK_RELEASE_TIMES_KEY`.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
UPDATE_STATE_FILE: Path = DEFAULT_STATE_DIR / "update_state.json"
|
|
59
|
+
"""Persistent flags for the update-notification UX.
|
|
60
|
+
|
|
61
|
+
Tracks which version the user has been notified about (`notified_version`,
|
|
62
|
+
`notified_at`) and the most recent version they've seen the splash for
|
|
63
|
+
(`seen_version`, `seen_at`). Read by `should_notify_update` and friends
|
|
64
|
+
to suppress repeat notifications across invocations. Auto-update opt-outs
|
|
65
|
+
live in `config.toml`, not here.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
CACHE_TTL = 86_400 # 24 hours
|
|
69
|
+
"""Maximum age in seconds before `CACHE_FILE` entries are considered stale.
|
|
70
|
+
|
|
71
|
+
A cached `latest_version.json` younger than this is reused without an HTTP
|
|
72
|
+
call to PyPI; older payloads trigger a fresh fetch. Set conservatively at
|
|
73
|
+
24h since release cadence is on the order of days, not minutes.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
INSTALLED_AGE_NOTICE_DAYS = 7
|
|
77
|
+
"""Minimum installed-version age before update notices call it out explicitly."""
|
|
78
|
+
|
|
79
|
+
INSTALLED_STALE_NOTICE_DAYS = 14
|
|
80
|
+
"""Minimum installed-version age (days) before the stale-install banner shows."""
|
|
81
|
+
|
|
82
|
+
_SDK_RELEASE_TIMES_KEY = "sdk_release_times"
|
|
83
|
+
"""`CACHE_FILE` key for cached SDK upload timestamps, keyed by version string."""
|
|
84
|
+
|
|
85
|
+
_RELEASE_PRERELEASE_DEPS_KEY = "release_requires_prereleases"
|
|
86
|
+
"""`CACHE_FILE` key for release versions that require pre-release dependencies."""
|
|
87
|
+
|
|
88
|
+
InstallMethod = Literal["uv", "brew", "other", "unknown"]
|
|
89
|
+
|
|
90
|
+
FALLBACK_UPGRADE_COMMAND = f"uv tool install -U {DISTRIBUTION_NAME}"
|
|
91
|
+
"""Generic upgrade hint used when install-method detection fails.
|
|
92
|
+
|
|
93
|
+
Callers that surface an upgrade command in user-facing text should prefer
|
|
94
|
+
`upgrade_command()`; this constant exists so those callers have something
|
|
95
|
+
to render when detection raises unexpectedly. The documented install path
|
|
96
|
+
is `uv tool install` (see `scripts/install.sh`), so the uv command is the
|
|
97
|
+
right display fallback. Uses `uv tool install -U` rather than `uv tool
|
|
98
|
+
upgrade` for the same receipt-pin reason documented on `_UPGRADE_COMMANDS`:
|
|
99
|
+
showing a user the `upgrade` form would hand them a command that silently
|
|
100
|
+
stays on the old version for a pinned install. Execution paths still refuse
|
|
101
|
+
unrecognized installs instead of updating a separate environment.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_UPGRADE_COMMANDS: dict[InstallMethod, str] = {
|
|
106
|
+
# Use `uv tool install -U` instead of `uv tool upgrade`: the latter
|
|
107
|
+
# *respects* the requirement string baked into the uv tool receipt by the
|
|
108
|
+
# original install (or by any prior `dependency_refresh_command` that
|
|
109
|
+
# wrote `deepagents-code==<old_version>` into the receipt). When that
|
|
110
|
+
# requirement is pinned, `uv tool upgrade` "succeeds" but re-installs the
|
|
111
|
+
# same pinned version, silently leaving the user behind latest. A bare
|
|
112
|
+
# `uv tool install -U deepagents-code` rewrites the receipt's requirement
|
|
113
|
+
# to an unpinned `deepagents-code` and re-resolves to the latest stable
|
|
114
|
+
# release, which is what users running `/update` actually want.
|
|
115
|
+
# `dependency_refresh_command` builds the inverse command for the
|
|
116
|
+
# explicit "stay on this version, refresh deps" flow.
|
|
117
|
+
"uv": FALLBACK_UPGRADE_COMMAND,
|
|
118
|
+
"brew": f"brew upgrade {DISTRIBUTION_NAME}",
|
|
119
|
+
}
|
|
120
|
+
"""Upgrade commands keyed by install method.
|
|
121
|
+
|
|
122
|
+
`perform_upgrade` runs only the command matching the detected install method;
|
|
123
|
+
no fallback chain. Unknown non-editable installs are refused rather than
|
|
124
|
+
upgraded with a different package manager, because that can update a separate
|
|
125
|
+
environment from the one currently providing `dcode`.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
_UV_PRERELEASE_UPGRADE_COMMAND = f"{FALLBACK_UPGRADE_COMMAND} --prerelease allow"
|
|
129
|
+
"""uv upgrade command that opts into alpha/beta/rc release resolution.
|
|
130
|
+
|
|
131
|
+
Uses `uv tool install -U` (not `uv tool upgrade`) for the same receipt-pin
|
|
132
|
+
reason documented on `_UPGRADE_COMMANDS`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
_PRERELEASE_UNSUPPORTED_MESSAGE = (
|
|
136
|
+
"Pre-release updates aren't supported for this install. Reinstall with "
|
|
137
|
+
"pre-releases enabled:\n"
|
|
138
|
+
' curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_PRERELEASE="allow" bash'
|
|
139
|
+
)
|
|
140
|
+
"""User-facing reason a pre-release upgrade is refused on non-uv installs.
|
|
141
|
+
|
|
142
|
+
Points at the install script (uv under the hood) rather than raw uv commands,
|
|
143
|
+
since that one-liner is the path we promote.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
_UPGRADE_TIMEOUT = 120 # seconds
|
|
147
|
+
"""Wall-clock cap for `perform_upgrade` and `perform_install_extra`."""
|
|
148
|
+
|
|
149
|
+
_TERMINATE_WAIT_TIMEOUT = 5 # seconds
|
|
150
|
+
"""Cap on reaping a killed install subprocess so cleanup cannot hang launch."""
|
|
151
|
+
|
|
152
|
+
INSTALL_SCRIPT_COMMAND = "curl -LsSf https://langch.in/dcode | bash"
|
|
153
|
+
"""Promoted public install command for Deep Agents Code."""
|
|
154
|
+
|
|
155
|
+
UPDATE_LOG_DIR: Path = DEFAULT_STATE_DIR / "update_logs"
|
|
156
|
+
"""Directory for persisted update command logs."""
|
|
157
|
+
|
|
158
|
+
UPDATE_LOG_RETENTION_DAYS = 14
|
|
159
|
+
"""Delete update logs older than this many days."""
|
|
160
|
+
|
|
161
|
+
UPDATE_LOG_MAX_FILES = 10
|
|
162
|
+
"""Keep at most this many newest update logs."""
|
|
163
|
+
|
|
164
|
+
STARTUP_AUTO_UPDATE_FAILURE_COOLDOWN = CACHE_TTL
|
|
165
|
+
"""Seconds to suppress same-version startup auto-update retries after failure."""
|
|
166
|
+
|
|
167
|
+
RESUME_AUTO_UPDATE_GRACE_PERIOD = 7 * 24 * 60 * 60
|
|
168
|
+
"""Seconds resumed sessions may bypass the startup auto-update path."""
|
|
169
|
+
|
|
170
|
+
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY = "startup_auto_update_failed_version"
|
|
171
|
+
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY = "startup_auto_update_failed_at"
|
|
172
|
+
_STARTUP_AUTO_UPDATE_FAILURE_KEYS: tuple[str, ...] = (
|
|
173
|
+
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY,
|
|
174
|
+
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY,
|
|
175
|
+
)
|
|
176
|
+
_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY = "resume_auto_update_deferred_at"
|
|
177
|
+
|
|
178
|
+
UpgradeProgressCallback = Callable[[str], Awaitable[None] | None]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_version(v: str) -> Version:
|
|
182
|
+
"""Parse a PEP 440 version string into a comparable `Version` object.
|
|
183
|
+
|
|
184
|
+
Supports stable (`1.2.3`) and pre-release (`1.2.3a1`, `1.2.3rc2`) versions.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
v: Version string like `'1.2.3'` or `'1.2.3a1'`.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
A `packaging.version.Version` instance.
|
|
191
|
+
"""
|
|
192
|
+
return Version(v.strip()) # raises InvalidVersion for non-PEP 440 strings
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_installed_version_at_least(version: str) -> bool:
|
|
196
|
+
"""Return whether installed package metadata is at least `version`."""
|
|
197
|
+
try:
|
|
198
|
+
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
|
199
|
+
|
|
200
|
+
installed = _parse_version(pkg_version(DISTRIBUTION_NAME))
|
|
201
|
+
target = _parse_version(version)
|
|
202
|
+
except (InvalidVersion, PackageNotFoundError):
|
|
203
|
+
return False
|
|
204
|
+
return installed >= target
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _latest_from_releases(
|
|
208
|
+
releases: Mapping[str, Sequence[object]],
|
|
209
|
+
*,
|
|
210
|
+
include_prereleases: bool,
|
|
211
|
+
) -> str | None:
|
|
212
|
+
"""Pick the newest version from a PyPI `releases` mapping.
|
|
213
|
+
|
|
214
|
+
Skips versions with no uploaded files (empty entries) and, when
|
|
215
|
+
*include_prereleases* is `False`, skips pre-release versions.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
releases: The `releases` dict from the PyPI JSON API.
|
|
219
|
+
include_prereleases: Whether to consider pre-release versions.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The highest matching version string, or `None` if none qualify.
|
|
223
|
+
"""
|
|
224
|
+
best: Version | None = None
|
|
225
|
+
best_str: str | None = None
|
|
226
|
+
for ver_str, files in releases.items():
|
|
227
|
+
if not files:
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
ver = Version(ver_str)
|
|
231
|
+
except InvalidVersion:
|
|
232
|
+
logger.debug("Skipping unparseable release key: %s", ver_str)
|
|
233
|
+
continue
|
|
234
|
+
if not include_prereleases and ver.is_prerelease:
|
|
235
|
+
continue
|
|
236
|
+
if best is None or ver > best:
|
|
237
|
+
best = ver
|
|
238
|
+
best_str = ver_str
|
|
239
|
+
return best_str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_cached_update_available() -> tuple[bool, str | None]:
|
|
243
|
+
"""Check for updates using only a fresh local cache entry.
|
|
244
|
+
|
|
245
|
+
This is the startup fast path: it never contacts PyPI. Stale, missing,
|
|
246
|
+
corrupt, or unparsable cache data is treated as "no cached update answer" so
|
|
247
|
+
callers can launch immediately and let a background update check refresh the
|
|
248
|
+
cache later.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
A `(available, latest)` tuple. `latest` is `None` when the cache cannot
|
|
252
|
+
provide a fresh answer.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
installed = _parse_version(__version__)
|
|
256
|
+
except InvalidVersion:
|
|
257
|
+
logger.warning(
|
|
258
|
+
"Installed version %r is not PEP 440 compliant; "
|
|
259
|
+
"cache-only update checks disabled for this install",
|
|
260
|
+
__version__,
|
|
261
|
+
)
|
|
262
|
+
return False, None
|
|
263
|
+
|
|
264
|
+
cache_key = "version_prerelease" if installed.is_prerelease else "version"
|
|
265
|
+
try:
|
|
266
|
+
if not CACHE_FILE.exists():
|
|
267
|
+
return False, None
|
|
268
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
269
|
+
if not isinstance(data, dict):
|
|
270
|
+
return False, None
|
|
271
|
+
checked_at = data.get("checked_at")
|
|
272
|
+
checked_at = _coerce_checked_at(checked_at)
|
|
273
|
+
if checked_at is None:
|
|
274
|
+
return False, None
|
|
275
|
+
if time.time() - checked_at >= CACHE_TTL:
|
|
276
|
+
return False, None
|
|
277
|
+
value = data.get(cache_key)
|
|
278
|
+
if not isinstance(value, str):
|
|
279
|
+
return False, None
|
|
280
|
+
return _parse_version(value) > installed, value
|
|
281
|
+
except (OSError, json.JSONDecodeError, TypeError, InvalidVersion):
|
|
282
|
+
logger.debug("Failed to read cache-only update answer", exc_info=True)
|
|
283
|
+
return False, None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _coerce_checked_at(value: object) -> float | None:
|
|
287
|
+
"""Return a valid epoch timestamp from cached state, or `None`."""
|
|
288
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
289
|
+
return None
|
|
290
|
+
checked_at = float(value)
|
|
291
|
+
if not math.isfinite(checked_at):
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
datetime.fromtimestamp(checked_at, tz=UTC)
|
|
295
|
+
except (OverflowError, OSError, ValueError):
|
|
296
|
+
return None
|
|
297
|
+
return checked_at
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_last_update_check_time() -> float | None:
|
|
301
|
+
"""Return the epoch time of the last PyPI update check, or `None`.
|
|
302
|
+
|
|
303
|
+
Reads the `checked_at` stamp recorded in `CACHE_FILE` when the update cache
|
|
304
|
+
is written (primarily by `get_latest_version`; also seeded by
|
|
305
|
+
`_write_release_requires_prereleases`). Missing, corrupt, or non-numeric
|
|
306
|
+
data fail-soft to `None` so callers can render an "unknown" state without
|
|
307
|
+
contacting the network.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
if not CACHE_FILE.exists():
|
|
311
|
+
return None
|
|
312
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
313
|
+
if not isinstance(data, dict):
|
|
314
|
+
return None
|
|
315
|
+
checked_at = data.get("checked_at")
|
|
316
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
317
|
+
logger.debug("Failed to read last update check time", exc_info=True)
|
|
318
|
+
return None
|
|
319
|
+
return _coerce_checked_at(checked_at)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _requires_prerelease_dependency(requirements: Sequence[object] | None) -> bool:
|
|
323
|
+
"""Return whether any requirement specifier names a pre-release version.
|
|
324
|
+
|
|
325
|
+
Accepts the raw `Requires-Dist` list from PyPI metadata, which may contain
|
|
326
|
+
non-string or non-PEP-508 junk; such entries are skipped rather than raising
|
|
327
|
+
so one malformed line cannot poison the whole check.
|
|
328
|
+
|
|
329
|
+
The check is intentionally operator- and marker-agnostic: it returns `True`
|
|
330
|
+
if *any* specifier across *any* requirement pins a pre-release version,
|
|
331
|
+
regardless of the operator (`==`, `>=`, even `!=`) or environment markers
|
|
332
|
+
(extras, `python_version`). This errs toward `True`, which is the safe
|
|
333
|
+
direction — opting `uv` into `--prerelease allow` still resolves stable
|
|
334
|
+
releases correctly, so a false positive only widens the candidate set and
|
|
335
|
+
never strands a user. Do not "tighten" this to the dangerous direction
|
|
336
|
+
without revisiting the fallback asymmetry in `release_requires_prereleases`.
|
|
337
|
+
"""
|
|
338
|
+
if not requirements:
|
|
339
|
+
return False
|
|
340
|
+
for raw in requirements:
|
|
341
|
+
if not isinstance(raw, str):
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
requirement = Requirement(raw)
|
|
345
|
+
except InvalidRequirement:
|
|
346
|
+
logger.debug("Skipping unparseable Requires-Dist entry: %r", raw)
|
|
347
|
+
continue
|
|
348
|
+
for specifier in requirement.specifier:
|
|
349
|
+
try:
|
|
350
|
+
version = Version(specifier.version)
|
|
351
|
+
except InvalidVersion:
|
|
352
|
+
logger.debug(
|
|
353
|
+
"Skipping unparseable requirement version: %r",
|
|
354
|
+
specifier.version,
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
if version.is_prerelease:
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _atomic_write_cache(data: dict[str, Any]) -> None:
|
|
363
|
+
"""Write `data` to `CACHE_FILE` as JSON atomically.
|
|
364
|
+
|
|
365
|
+
A plain `write_text` truncates the file before writing, so a crash or a
|
|
366
|
+
concurrent reader/writer can observe a half-written cache. Serializing to a
|
|
367
|
+
sibling temp file and `os.replace`-ing it into place makes the swap atomic,
|
|
368
|
+
so readers always see either the old or new contents — never a partial one.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
OSError: If the cache directory or temp file cannot be written, or the
|
|
372
|
+
atomic replace fails. Callers handle reporting.
|
|
373
|
+
"""
|
|
374
|
+
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
376
|
+
dir=CACHE_FILE.parent, prefix=".latest_version-", suffix=".tmp"
|
|
377
|
+
)
|
|
378
|
+
tmp_path = Path(tmp_name)
|
|
379
|
+
try:
|
|
380
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
381
|
+
json.dump(data, handle)
|
|
382
|
+
tmp_path.replace(CACHE_FILE)
|
|
383
|
+
except OSError:
|
|
384
|
+
with suppress(OSError):
|
|
385
|
+
tmp_path.unlink()
|
|
386
|
+
raise
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _write_release_requires_prereleases(version: str, requires: bool) -> None:
|
|
390
|
+
"""Cache whether a release needs uv's pre-release resolver opt-in."""
|
|
391
|
+
try:
|
|
392
|
+
data: dict[str, Any]
|
|
393
|
+
if CACHE_FILE.exists():
|
|
394
|
+
loaded = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
395
|
+
data = loaded if isinstance(loaded, dict) else {}
|
|
396
|
+
else:
|
|
397
|
+
data = {}
|
|
398
|
+
values = data.get(_RELEASE_PRERELEASE_DEPS_KEY)
|
|
399
|
+
if not isinstance(values, dict):
|
|
400
|
+
values = {}
|
|
401
|
+
values[version] = requires
|
|
402
|
+
data[_RELEASE_PRERELEASE_DEPS_KEY] = values
|
|
403
|
+
data.setdefault("checked_at", time.time())
|
|
404
|
+
_atomic_write_cache(data)
|
|
405
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
406
|
+
logger.debug(
|
|
407
|
+
"Failed to write release pre-release dependency cache",
|
|
408
|
+
exc_info=True,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def get_latest_version(
|
|
413
|
+
*,
|
|
414
|
+
bypass_cache: bool = False,
|
|
415
|
+
include_prereleases: bool = False,
|
|
416
|
+
) -> str | None:
|
|
417
|
+
"""Fetch the latest deepagents-code version from PyPI, with caching.
|
|
418
|
+
|
|
419
|
+
Results are cached to `CACHE_FILE` to avoid repeated network calls.
|
|
420
|
+
The cache stores both the latest stable and pre-release versions so a
|
|
421
|
+
single PyPI request serves both code paths.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
bypass_cache: Skip the cache and always hit PyPI.
|
|
425
|
+
include_prereleases: When `True`, consider pre-release versions
|
|
426
|
+
(alpha, beta, rc). Stable users should leave this `False`.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The latest version string, or `None` on any failure.
|
|
430
|
+
"""
|
|
431
|
+
cache_key = "version_prerelease" if include_prereleases else "version"
|
|
432
|
+
cached_version: str | None = None
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
if not bypass_cache and CACHE_FILE.exists():
|
|
436
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
437
|
+
fresh = time.time() - data.get("checked_at", 0) < CACHE_TTL
|
|
438
|
+
if fresh and cache_key in data:
|
|
439
|
+
value = data[cache_key]
|
|
440
|
+
cached_version = value if isinstance(value, str) else None
|
|
441
|
+
release_times = data.get("release_times")
|
|
442
|
+
has_installed_release_time = (
|
|
443
|
+
isinstance(release_times, dict) and __version__ in release_times
|
|
444
|
+
)
|
|
445
|
+
if fresh and cache_key in data and has_installed_release_time:
|
|
446
|
+
return cached_version
|
|
447
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
448
|
+
logger.debug("Failed to read update-check cache", exc_info=True)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
import requests
|
|
452
|
+
except ImportError:
|
|
453
|
+
logger.warning(
|
|
454
|
+
"requests package not installed — update checks disabled. "
|
|
455
|
+
"Install with: uv tool install --reinstall -U "
|
|
456
|
+
f"{DISTRIBUTION_NAME} --with requests"
|
|
457
|
+
)
|
|
458
|
+
return cached_version
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
resp = requests.get(
|
|
462
|
+
PYPI_URL,
|
|
463
|
+
headers={"User-Agent": USER_AGENT},
|
|
464
|
+
timeout=3,
|
|
465
|
+
)
|
|
466
|
+
resp.raise_for_status()
|
|
467
|
+
payload = resp.json()
|
|
468
|
+
info = payload.get("info")
|
|
469
|
+
if not isinstance(info, dict):
|
|
470
|
+
logger.debug("PyPI response missing object 'info' key")
|
|
471
|
+
return cached_version
|
|
472
|
+
value = info.get("version")
|
|
473
|
+
if not isinstance(value, str):
|
|
474
|
+
logger.debug("PyPI response missing string 'info.version' key")
|
|
475
|
+
return cached_version
|
|
476
|
+
stable = value
|
|
477
|
+
releases: dict[str, list[object]] = payload.get("releases", {})
|
|
478
|
+
if not releases:
|
|
479
|
+
logger.debug("PyPI response missing or empty 'releases' key")
|
|
480
|
+
prerelease = _latest_from_releases(releases, include_prereleases=True)
|
|
481
|
+
stable_requires_prereleases = _requires_prerelease_dependency(
|
|
482
|
+
info.get("requires_dist")
|
|
483
|
+
)
|
|
484
|
+
except (requests.RequestException, OSError, KeyError, json.JSONDecodeError):
|
|
485
|
+
logger.debug("Failed to fetch latest version from PyPI", exc_info=True)
|
|
486
|
+
return cached_version
|
|
487
|
+
|
|
488
|
+
release_times = _extract_release_times(
|
|
489
|
+
payload, stable=stable, prerelease=prerelease, installed=__version__
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Preserve per-version pre-release-dependency entries written by
|
|
493
|
+
# `_write_release_requires_prereleases` for *other* versions; this refresh
|
|
494
|
+
# only knows the answer for `stable`, so merge rather than overwrite the map
|
|
495
|
+
# (otherwise a routine check would evict a cached answer and force a re-fetch
|
|
496
|
+
# that, on a PyPI hiccup, falls back to the unsafe stable-only default).
|
|
497
|
+
prerelease_deps: dict[str, Any] = {}
|
|
498
|
+
try:
|
|
499
|
+
if CACHE_FILE.exists():
|
|
500
|
+
existing = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
501
|
+
if isinstance(existing, dict):
|
|
502
|
+
cached_deps = existing.get(_RELEASE_PRERELEASE_DEPS_KEY)
|
|
503
|
+
if isinstance(cached_deps, dict):
|
|
504
|
+
prerelease_deps = cached_deps
|
|
505
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
506
|
+
logger.debug("Failed to read cached pre-release deps before refresh")
|
|
507
|
+
prerelease_deps[stable] = stable_requires_prereleases
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
_atomic_write_cache(
|
|
511
|
+
{
|
|
512
|
+
"version": stable,
|
|
513
|
+
"version_prerelease": prerelease,
|
|
514
|
+
"release_times": release_times,
|
|
515
|
+
_RELEASE_PRERELEASE_DEPS_KEY: prerelease_deps,
|
|
516
|
+
"checked_at": time.time(),
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
except OSError:
|
|
520
|
+
logger.debug("Failed to write update-check cache", exc_info=True)
|
|
521
|
+
|
|
522
|
+
return prerelease if include_prereleases else stable
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def release_requires_prereleases(
|
|
526
|
+
version: str | None,
|
|
527
|
+
*,
|
|
528
|
+
bypass_cache: bool = False,
|
|
529
|
+
) -> bool:
|
|
530
|
+
"""Return whether installing `version` needs uv pre-release resolution.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
version: `deepagents-code` version to inspect.
|
|
534
|
+
bypass_cache: Skip cached release metadata and fetch PyPI directly.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
`True` when the release metadata pins or bounds a pre-release dependency.
|
|
538
|
+
|
|
539
|
+
Note:
|
|
540
|
+
On any lookup failure (no `requests`, network/parse error) this returns
|
|
541
|
+
`False` — i.e. "stable-only resolution". That is deliberately
|
|
542
|
+
conservative rather than fail-safe: the truly safe default would be to
|
|
543
|
+
allow pre-releases, but a spurious `True` would make
|
|
544
|
+
`prerelease_upgrade_supported` *refuse* the upgrade outright on non-uv
|
|
545
|
+
installs (Homebrew/other), regressing the common case. `False` keeps
|
|
546
|
+
those installs upgradable; the cost is that, during a PyPI outage, a
|
|
547
|
+
stable release that genuinely pins a pre-release dependency may be
|
|
548
|
+
installed stable-only. Failures are logged at `warning` so the blind
|
|
549
|
+
decision is at least visible.
|
|
550
|
+
"""
|
|
551
|
+
if not version:
|
|
552
|
+
return False
|
|
553
|
+
try:
|
|
554
|
+
if not bypass_cache and CACHE_FILE.exists():
|
|
555
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
556
|
+
if isinstance(data, dict):
|
|
557
|
+
values = data.get(_RELEASE_PRERELEASE_DEPS_KEY)
|
|
558
|
+
if isinstance(values, dict) and isinstance(values.get(version), bool):
|
|
559
|
+
return values[version]
|
|
560
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
561
|
+
logger.debug(
|
|
562
|
+
"Failed to read release pre-release dependency cache",
|
|
563
|
+
exc_info=True,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
import requests
|
|
568
|
+
except ImportError:
|
|
569
|
+
logger.warning(
|
|
570
|
+
"requests package not installed — cannot check whether v%s pins a "
|
|
571
|
+
"pre-release dependency; assuming stable-only resolution",
|
|
572
|
+
version,
|
|
573
|
+
)
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
url = f"{PYPI_URL.removesuffix('/json')}/{version}/json"
|
|
578
|
+
resp = requests.get(
|
|
579
|
+
url,
|
|
580
|
+
headers={"User-Agent": USER_AGENT},
|
|
581
|
+
timeout=3,
|
|
582
|
+
)
|
|
583
|
+
resp.raise_for_status()
|
|
584
|
+
payload = resp.json()
|
|
585
|
+
info = payload.get("info")
|
|
586
|
+
requires = info.get("requires_dist") if isinstance(info, dict) else None
|
|
587
|
+
result = _requires_prerelease_dependency(requires)
|
|
588
|
+
except (requests.RequestException, OSError, KeyError, json.JSONDecodeError):
|
|
589
|
+
logger.warning(
|
|
590
|
+
"Failed to fetch dependency metadata for v%s from PyPI; assuming "
|
|
591
|
+
"stable-only resolution",
|
|
592
|
+
version,
|
|
593
|
+
exc_info=True,
|
|
594
|
+
)
|
|
595
|
+
return False
|
|
596
|
+
|
|
597
|
+
_write_release_requires_prereleases(version, result)
|
|
598
|
+
return result
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _extract_release_times(
|
|
602
|
+
payload: dict[str, Any],
|
|
603
|
+
*,
|
|
604
|
+
stable: str,
|
|
605
|
+
prerelease: str | None,
|
|
606
|
+
installed: str | None = None,
|
|
607
|
+
) -> dict[str, str]:
|
|
608
|
+
"""Pull `upload_time_iso_8601` for the given versions out of a PyPI payload.
|
|
609
|
+
|
|
610
|
+
PyPI lists per-file uploads; the first file's timestamp is used as a
|
|
611
|
+
stand-in for the release's publish time (files typically land within
|
|
612
|
+
seconds of each other). Looks up both versions under `releases[ver]`
|
|
613
|
+
rather than `payload["urls"]`, which reflects the project's
|
|
614
|
+
`info.version` and may not match `stable` when the latest on PyPI is
|
|
615
|
+
a pre-release.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
payload: Parsed PyPI JSON response.
|
|
619
|
+
stable: Latest stable version string.
|
|
620
|
+
prerelease: Latest pre-release version string, if any.
|
|
621
|
+
installed: Currently installed version string, if it should be cached.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Mapping of version string to ISO-8601 upload time. Silently drops
|
|
625
|
+
versions whose timestamp is missing or malformed.
|
|
626
|
+
"""
|
|
627
|
+
times: dict[str, str] = {}
|
|
628
|
+
releases = payload.get("releases")
|
|
629
|
+
if not isinstance(releases, dict):
|
|
630
|
+
return times
|
|
631
|
+
for ver in (stable, prerelease, installed):
|
|
632
|
+
if not ver:
|
|
633
|
+
continue
|
|
634
|
+
files = releases.get(ver)
|
|
635
|
+
if not isinstance(files, list) or not files:
|
|
636
|
+
continue
|
|
637
|
+
ts = _upload_time(files[0])
|
|
638
|
+
if ts:
|
|
639
|
+
times[ver] = ts
|
|
640
|
+
return times
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _upload_time(file_entry: object) -> str | None:
|
|
644
|
+
"""Return `upload_time_iso_8601` from a PyPI file entry, or `None`."""
|
|
645
|
+
if not isinstance(file_entry, dict):
|
|
646
|
+
return None
|
|
647
|
+
# `isinstance(..., dict)` narrows to `dict[Unknown, Unknown]`, so `.get()`
|
|
648
|
+
# overload resolution is ambiguous. PyPI payloads are str-keyed in practice
|
|
649
|
+
# and the `isinstance(value, str)` check below validates the result anyway.
|
|
650
|
+
value = file_entry.get("upload_time_iso_8601") # ty: ignore[invalid-argument-type]
|
|
651
|
+
return value if isinstance(value, str) else None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def get_release_time(version: str | None) -> str | None:
|
|
655
|
+
"""Return the cached ISO-8601 upload time for `version`, or `None`.
|
|
656
|
+
|
|
657
|
+
Only versions captured during a prior `get_latest_version` call are
|
|
658
|
+
available; unknown versions, or a `None` input, return `None`.
|
|
659
|
+
"""
|
|
660
|
+
if not version:
|
|
661
|
+
return None
|
|
662
|
+
try:
|
|
663
|
+
if CACHE_FILE.exists():
|
|
664
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
665
|
+
if isinstance(data, dict):
|
|
666
|
+
times = data.get("release_times")
|
|
667
|
+
if isinstance(times, dict):
|
|
668
|
+
value = times.get(version)
|
|
669
|
+
if isinstance(value, str):
|
|
670
|
+
return value
|
|
671
|
+
except (OSError, json.JSONDecodeError):
|
|
672
|
+
logger.debug("Failed to read release_times from cache", exc_info=True)
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _format_age_from_iso(iso: str | None) -> str:
|
|
677
|
+
"""Return `'released Nd ago'` for an ISO-8601 timestamp, or `""` on failure."""
|
|
678
|
+
if not iso:
|
|
679
|
+
return ""
|
|
680
|
+
from deepagents_code.sessions import format_relative_timestamp
|
|
681
|
+
|
|
682
|
+
age = format_relative_timestamp(iso)
|
|
683
|
+
return f"released {age}" if age else ""
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def format_release_age(version: str | None) -> str:
|
|
687
|
+
"""Return a human-readable age for `version` (e.g., `'released 3d ago'`).
|
|
688
|
+
|
|
689
|
+
Returns an empty string when the upload time is unknown (cache entry
|
|
690
|
+
lacks `release_times` for this version, or a `None` version) so callers
|
|
691
|
+
can concatenate unconditionally.
|
|
692
|
+
"""
|
|
693
|
+
return _format_age_from_iso(get_release_time(version))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def format_age_suffix(version: str | None) -> str:
|
|
697
|
+
"""Return `", released Nd ago"` for `version`, or `""` when unknown.
|
|
698
|
+
|
|
699
|
+
The `", "` separator is included so callers can splice the age into a
|
|
700
|
+
parenthetical unconditionally — if the age is unknown, the empty
|
|
701
|
+
string collapses cleanly into the surrounding text.
|
|
702
|
+
"""
|
|
703
|
+
age = format_release_age(version)
|
|
704
|
+
return f", {age}" if age else ""
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def format_release_age_parenthetical(version: str | None) -> str:
|
|
708
|
+
"""Return `" (released Nd ago)"` for `version`, or `""` when unknown."""
|
|
709
|
+
age = format_release_age(version)
|
|
710
|
+
return f" ({age})" if age else ""
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _days_old_from_iso(iso: str | None) -> int | None:
|
|
714
|
+
"""Return whole elapsed days for an ISO-8601 timestamp, or `None` on failure."""
|
|
715
|
+
if not iso:
|
|
716
|
+
return None
|
|
717
|
+
try:
|
|
718
|
+
dt = datetime.fromisoformat(iso).astimezone()
|
|
719
|
+
except (ValueError, TypeError):
|
|
720
|
+
logger.debug(
|
|
721
|
+
"Failed to parse release timestamp %r for installed age",
|
|
722
|
+
iso,
|
|
723
|
+
exc_info=True,
|
|
724
|
+
)
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
days = (datetime.now(tz=dt.tzinfo) - dt).days
|
|
728
|
+
return max(days, 0)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def format_installed_age_suffix(version: str | None) -> str:
|
|
732
|
+
"""Return `" (N days old)"` for installed versions at least a week old."""
|
|
733
|
+
days = _days_old_from_iso(get_release_time(version))
|
|
734
|
+
if days is None or days < INSTALLED_AGE_NOTICE_DAYS:
|
|
735
|
+
return ""
|
|
736
|
+
unit = "day" if days == 1 else "days"
|
|
737
|
+
return f" ({days} {unit} old)"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def installed_days_old() -> int | None:
|
|
741
|
+
"""Return whole days since the installed version's release, or `None`.
|
|
742
|
+
|
|
743
|
+
Cache-only (`get_release_time`), so it never blocks on the network.
|
|
744
|
+
Returns `None` when the release time is unknown (cold cache or an
|
|
745
|
+
unparseable timestamp). Best-effort: any unexpected error degrades to
|
|
746
|
+
`None` rather than propagating, since this runs on the startup path.
|
|
747
|
+
"""
|
|
748
|
+
try:
|
|
749
|
+
return _days_old_from_iso(get_release_time(__version__))
|
|
750
|
+
except Exception:
|
|
751
|
+
logger.debug("Failed to compute installed version age", exc_info=True)
|
|
752
|
+
return None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def is_installation_stale() -> bool:
|
|
756
|
+
"""Return whether an older installed version should warn about updating.
|
|
757
|
+
|
|
758
|
+
`True` only when a fresh cache says an update is available and the installed
|
|
759
|
+
version's release is at least `INSTALLED_STALE_NOTICE_DAYS` old. Returns
|
|
760
|
+
`False` for editable/dev installs (release age is meaningless there), when
|
|
761
|
+
update checks are disabled (the opt-out silences this too), and when the age
|
|
762
|
+
or update answer is unknown.
|
|
763
|
+
|
|
764
|
+
Best-effort: any unexpected error degrades to `False` rather than
|
|
765
|
+
propagating, so this cosmetic check can never abort TUI startup.
|
|
766
|
+
"""
|
|
767
|
+
from deepagents_code.config import _is_editable_install
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
if _is_editable_install() or not is_update_check_enabled():
|
|
771
|
+
return False
|
|
772
|
+
available, _ = get_cached_update_available()
|
|
773
|
+
if not available:
|
|
774
|
+
return False
|
|
775
|
+
days = installed_days_old()
|
|
776
|
+
except Exception:
|
|
777
|
+
logger.debug("Failed to determine installation staleness", exc_info=True)
|
|
778
|
+
return False
|
|
779
|
+
return days is not None and days >= INSTALLED_STALE_NOTICE_DAYS
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def get_sdk_release_time(
|
|
783
|
+
version: str | None, *, bypass_cache: bool = False
|
|
784
|
+
) -> str | None:
|
|
785
|
+
"""Return the ISO-8601 upload time for `deepagents` SDK `version`.
|
|
786
|
+
|
|
787
|
+
Reads from `CACHE_FILE` under `sdk_release_times`, falling back to a
|
|
788
|
+
single PyPI fetch on cache miss and writing the result back so
|
|
789
|
+
subsequent calls stay local.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
version: Installed SDK version string.
|
|
793
|
+
bypass_cache: Skip the cache read and always hit PyPI.
|
|
794
|
+
|
|
795
|
+
The result is still written back to the cache.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
The ISO-8601 upload timestamp, or `None` on any failure (missing
|
|
799
|
+
version, unresolvable on PyPI, `requests` unavailable, or
|
|
800
|
+
network error).
|
|
801
|
+
"""
|
|
802
|
+
if not version:
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
if not bypass_cache and CACHE_FILE.exists():
|
|
807
|
+
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
808
|
+
if isinstance(data, dict):
|
|
809
|
+
times = data.get(_SDK_RELEASE_TIMES_KEY)
|
|
810
|
+
if isinstance(times, dict):
|
|
811
|
+
cached = times.get(version)
|
|
812
|
+
if isinstance(cached, str):
|
|
813
|
+
return cached
|
|
814
|
+
except (OSError, json.JSONDecodeError):
|
|
815
|
+
logger.debug("Failed to read sdk release_times from cache", exc_info=True)
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
import requests
|
|
819
|
+
except ImportError:
|
|
820
|
+
logger.debug("requests unavailable — SDK release time lookup disabled")
|
|
821
|
+
return None
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
resp = requests.get(
|
|
825
|
+
SDK_PYPI_URL,
|
|
826
|
+
headers={"User-Agent": USER_AGENT},
|
|
827
|
+
timeout=3,
|
|
828
|
+
)
|
|
829
|
+
resp.raise_for_status()
|
|
830
|
+
payload = resp.json()
|
|
831
|
+
releases = payload.get("releases")
|
|
832
|
+
if not isinstance(releases, dict):
|
|
833
|
+
return None
|
|
834
|
+
files = releases.get(version)
|
|
835
|
+
if not isinstance(files, list) or not files:
|
|
836
|
+
return None
|
|
837
|
+
iso = _upload_time(files[0])
|
|
838
|
+
except (requests.RequestException, OSError, json.JSONDecodeError):
|
|
839
|
+
logger.debug("Failed to fetch SDK release time from PyPI", exc_info=True)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
if iso:
|
|
843
|
+
_write_sdk_release_time(version, iso)
|
|
844
|
+
return iso
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _write_sdk_release_time(version: str, iso: str) -> None:
|
|
848
|
+
"""Merge a single SDK release timestamp into `CACHE_FILE`.
|
|
849
|
+
|
|
850
|
+
A corrupt existing cache is overwritten rather than propagating the
|
|
851
|
+
decode error — otherwise every caller would keep paying the PyPI
|
|
852
|
+
round-trip because the write never succeeds.
|
|
853
|
+
"""
|
|
854
|
+
data: dict[str, object] = {}
|
|
855
|
+
if CACHE_FILE.exists():
|
|
856
|
+
try:
|
|
857
|
+
raw = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
|
858
|
+
except json.JSONDecodeError:
|
|
859
|
+
logger.warning(
|
|
860
|
+
"SDK release-time cache is corrupt; overwriting", exc_info=True
|
|
861
|
+
)
|
|
862
|
+
except OSError:
|
|
863
|
+
logger.debug("Failed to read SDK release-time cache", exc_info=True)
|
|
864
|
+
return
|
|
865
|
+
else:
|
|
866
|
+
if isinstance(raw, dict):
|
|
867
|
+
data = raw
|
|
868
|
+
|
|
869
|
+
times: dict[str, str] = {}
|
|
870
|
+
existing = data.get(_SDK_RELEASE_TIMES_KEY)
|
|
871
|
+
if isinstance(existing, dict):
|
|
872
|
+
times.update(
|
|
873
|
+
{
|
|
874
|
+
k: v
|
|
875
|
+
for k, v in existing.items()
|
|
876
|
+
if isinstance(k, str) and isinstance(v, str)
|
|
877
|
+
}
|
|
878
|
+
)
|
|
879
|
+
times[version] = iso
|
|
880
|
+
data[_SDK_RELEASE_TIMES_KEY] = times
|
|
881
|
+
try:
|
|
882
|
+
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
883
|
+
CACHE_FILE.write_text(json.dumps(data), encoding="utf-8")
|
|
884
|
+
except OSError:
|
|
885
|
+
logger.debug("Failed to write SDK release time to cache", exc_info=True)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def format_sdk_release_age(version: str | None) -> str:
|
|
889
|
+
"""Return a human-readable age for SDK `version` (e.g., `'released 3d ago'`).
|
|
890
|
+
|
|
891
|
+
May trigger a single PyPI fetch on cache miss (3s timeout). Returns an
|
|
892
|
+
empty string on any failure so callers can concatenate unconditionally.
|
|
893
|
+
"""
|
|
894
|
+
return _format_age_from_iso(get_sdk_release_time(version))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def format_sdk_age_suffix(version: str | None) -> str:
|
|
898
|
+
"""Return `", released Nd ago"` for SDK `version`, or `""` when unknown.
|
|
899
|
+
|
|
900
|
+
The `", "` separator is included so callers can splice the age into a
|
|
901
|
+
line unconditionally — if the age is unknown, the empty string
|
|
902
|
+
collapses cleanly into the surrounding text. May trigger a single
|
|
903
|
+
PyPI fetch on cache miss.
|
|
904
|
+
"""
|
|
905
|
+
age = format_sdk_release_age(version)
|
|
906
|
+
return f", {age}" if age else ""
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _read_update_state() -> dict[str, object]:
|
|
910
|
+
"""Read the shared update state file.
|
|
911
|
+
|
|
912
|
+
Returns:
|
|
913
|
+
Parsed dict, or empty dict on missing/corrupt file.
|
|
914
|
+
"""
|
|
915
|
+
try:
|
|
916
|
+
if UPDATE_STATE_FILE.exists():
|
|
917
|
+
raw = json.loads(UPDATE_STATE_FILE.read_text(encoding="utf-8"))
|
|
918
|
+
if isinstance(raw, dict):
|
|
919
|
+
return raw
|
|
920
|
+
except (OSError, json.JSONDecodeError):
|
|
921
|
+
logger.debug("Failed to read update state file", exc_info=True)
|
|
922
|
+
return {}
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _write_update_state(
|
|
926
|
+
patch: dict[str, object], *, remove_keys: tuple[str, ...] = ()
|
|
927
|
+
) -> bool:
|
|
928
|
+
"""Merge *patch* into the shared update state file and drop *remove_keys*.
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
patch: Keys to merge into the existing state.
|
|
932
|
+
remove_keys: Keys to drop from the existing state before writing.
|
|
933
|
+
|
|
934
|
+
Returns:
|
|
935
|
+
`True` if the state was persisted, `False` if the write failed (the
|
|
936
|
+
error is logged, not raised, so callers stay fail-soft but can surface
|
|
937
|
+
the miss when a stale state has user-visible consequences).
|
|
938
|
+
"""
|
|
939
|
+
data = _read_update_state()
|
|
940
|
+
for key in remove_keys:
|
|
941
|
+
data.pop(key, None)
|
|
942
|
+
data.update(patch)
|
|
943
|
+
try:
|
|
944
|
+
UPDATE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
UPDATE_STATE_FILE.write_text(json.dumps(data), encoding="utf-8")
|
|
946
|
+
except OSError:
|
|
947
|
+
logger.warning(
|
|
948
|
+
"Failed to write update state to %s",
|
|
949
|
+
UPDATE_STATE_FILE,
|
|
950
|
+
exc_info=True,
|
|
951
|
+
)
|
|
952
|
+
return False
|
|
953
|
+
return True
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def should_defer_startup_auto_update_for_resume() -> bool:
|
|
957
|
+
"""Return whether a resumed session is still in its update grace period.
|
|
958
|
+
|
|
959
|
+
The first resumed launch starts a fixed grace period. Later resumes do not
|
|
960
|
+
extend it, so a resume-only workflow eventually follows the normal startup
|
|
961
|
+
auto-update path. If the marker cannot be persisted, fail closed and run
|
|
962
|
+
the update path rather than allowing an unbounded bypass.
|
|
963
|
+
"""
|
|
964
|
+
data = _read_update_state()
|
|
965
|
+
now = time.time()
|
|
966
|
+
deferred_at = _coerce_checked_at(data.get(_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY))
|
|
967
|
+
if deferred_at is None or deferred_at > now:
|
|
968
|
+
return _write_update_state({_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY: now})
|
|
969
|
+
return now - deferred_at < RESUME_AUTO_UPDATE_GRACE_PERIOD
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def clear_resume_auto_update_deferral() -> None:
|
|
973
|
+
"""Reset the resume grace period after a normal interactive launch."""
|
|
974
|
+
data = _read_update_state()
|
|
975
|
+
if _RESUME_AUTO_UPDATE_DEFERRED_AT_KEY not in data:
|
|
976
|
+
return
|
|
977
|
+
_write_update_state({}, remove_keys=(_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY,))
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def should_skip_startup_auto_update_after_failure(version: str) -> bool:
|
|
981
|
+
"""Return whether startup auto-update should skip a recently failed version."""
|
|
982
|
+
data = _read_update_state()
|
|
983
|
+
failed_version = data.get(_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY)
|
|
984
|
+
failed_at = data.get(_STARTUP_AUTO_UPDATE_FAILED_AT_KEY)
|
|
985
|
+
return bool(
|
|
986
|
+
failed_version == version
|
|
987
|
+
and isinstance(failed_at, (int, float))
|
|
988
|
+
and time.time() - failed_at < STARTUP_AUTO_UPDATE_FAILURE_COOLDOWN
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def mark_startup_auto_update_failed(version: str) -> bool:
|
|
993
|
+
"""Persist a same-version startup auto-update retry cooldown marker.
|
|
994
|
+
|
|
995
|
+
Returns:
|
|
996
|
+
`True` if the marker was written, `False` otherwise.
|
|
997
|
+
"""
|
|
998
|
+
return _write_update_state(
|
|
999
|
+
{
|
|
1000
|
+
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY: version,
|
|
1001
|
+
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY: time.time(),
|
|
1002
|
+
}
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def clear_startup_auto_update_failure(version: str) -> None:
|
|
1007
|
+
"""Clear a startup auto-update failure marker for `version` if present."""
|
|
1008
|
+
data = _read_update_state()
|
|
1009
|
+
if data.get(_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY) != version:
|
|
1010
|
+
return
|
|
1011
|
+
_write_update_state({}, remove_keys=_STARTUP_AUTO_UPDATE_FAILURE_KEYS)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def should_notify_update(latest: str) -> bool:
|
|
1015
|
+
"""Return whether the user should be notified about version *latest*.
|
|
1016
|
+
|
|
1017
|
+
Throttles notifications to at most once per `CACHE_TTL` period for a
|
|
1018
|
+
given version, preventing repeated banners every session.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
latest: The version string to check against.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
`True` if the user should see the update banner, `False` if the
|
|
1025
|
+
notification was already shown within the `CACHE_TTL` window.
|
|
1026
|
+
"""
|
|
1027
|
+
data = _read_update_state()
|
|
1028
|
+
notified_at = data.get("notified_at", 0)
|
|
1029
|
+
notified_version = data.get("notified_version")
|
|
1030
|
+
return not (
|
|
1031
|
+
isinstance(notified_at, (int, float))
|
|
1032
|
+
and notified_version == latest
|
|
1033
|
+
and time.time() - notified_at < CACHE_TTL
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def mark_update_notified(latest: str) -> None:
|
|
1038
|
+
"""Record that the user was notified about version *latest*.
|
|
1039
|
+
|
|
1040
|
+
Writes into the shared update state file so a subsequent
|
|
1041
|
+
`should_notify_update` call can suppress duplicate banners.
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
latest: The version string that was shown.
|
|
1045
|
+
"""
|
|
1046
|
+
_write_update_state({"notified_at": time.time(), "notified_version": latest})
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def clear_update_notified() -> None:
|
|
1050
|
+
"""Clear the "already notified" marker so the update modal re-opens next launch.
|
|
1051
|
+
|
|
1052
|
+
Removes both `notified_at` and `notified_version` from the shared
|
|
1053
|
+
update state file.
|
|
1054
|
+
"""
|
|
1055
|
+
_write_update_state({}, remove_keys=("notified_at", "notified_version"))
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def is_update_available(
|
|
1059
|
+
*,
|
|
1060
|
+
bypass_cache: bool = False,
|
|
1061
|
+
include_prereleases: bool | None = None,
|
|
1062
|
+
) -> tuple[bool, str | None]:
|
|
1063
|
+
"""Check whether a newer version of deepagents-code is available.
|
|
1064
|
+
|
|
1065
|
+
When the installed version is a pre-release (e.g. `0.0.35a1`),
|
|
1066
|
+
pre-release versions on PyPI are included in the comparison so alpha
|
|
1067
|
+
testers are notified of newer alphas and the eventual stable release.
|
|
1068
|
+
Stable installs only compare against stable PyPI releases unless
|
|
1069
|
+
`include_prereleases` is explicitly set.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
bypass_cache: Skip the cache and always hit PyPI.
|
|
1073
|
+
include_prereleases: Override whether alpha/beta/rc releases are
|
|
1074
|
+
considered. When `None`, this follows the installed version.
|
|
1075
|
+
|
|
1076
|
+
Returns:
|
|
1077
|
+
A `(available, latest)` tuple.
|
|
1078
|
+
|
|
1079
|
+
`latest` is the PyPI version string when it was fetched and parsed
|
|
1080
|
+
successfully, or `None` when the PyPI check itself fails (network
|
|
1081
|
+
error, unparseable response, or non-PEP 440 installed version).
|
|
1082
|
+
`available` is `True` only when `latest` is strictly newer than
|
|
1083
|
+
the installed version. Callers can therefore distinguish "already
|
|
1084
|
+
up to date" (`(False, "1.2.3")`) from "could not reach PyPI"
|
|
1085
|
+
(`(False, None)`).
|
|
1086
|
+
"""
|
|
1087
|
+
try:
|
|
1088
|
+
installed = _parse_version(__version__)
|
|
1089
|
+
except InvalidVersion:
|
|
1090
|
+
logger.warning(
|
|
1091
|
+
"Installed version %r is not PEP 440 compliant; "
|
|
1092
|
+
"update checks disabled for this install",
|
|
1093
|
+
__version__,
|
|
1094
|
+
)
|
|
1095
|
+
return False, None
|
|
1096
|
+
|
|
1097
|
+
include_prereleases = _resolve_include_prereleases(
|
|
1098
|
+
include_prereleases,
|
|
1099
|
+
installed=installed,
|
|
1100
|
+
)
|
|
1101
|
+
latest = get_latest_version(
|
|
1102
|
+
bypass_cache=bypass_cache,
|
|
1103
|
+
include_prereleases=include_prereleases,
|
|
1104
|
+
)
|
|
1105
|
+
if latest is None:
|
|
1106
|
+
return False, None
|
|
1107
|
+
|
|
1108
|
+
try:
|
|
1109
|
+
return _parse_version(latest) > installed, latest
|
|
1110
|
+
except InvalidVersion:
|
|
1111
|
+
logger.debug("Failed to compare versions", exc_info=True)
|
|
1112
|
+
return False, None
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
# ---------------------------------------------------------------------------
|
|
1116
|
+
# Install method detection
|
|
1117
|
+
# ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _resolve_include_prereleases(
|
|
1121
|
+
include_prereleases: bool | None,
|
|
1122
|
+
*,
|
|
1123
|
+
installed: Version | None = None,
|
|
1124
|
+
) -> bool:
|
|
1125
|
+
"""Resolve update channel preference from the requested or installed channel.
|
|
1126
|
+
|
|
1127
|
+
Args:
|
|
1128
|
+
include_prereleases: Explicit channel preference, or `None` to infer
|
|
1129
|
+
from the installed version.
|
|
1130
|
+
installed: Parsed installed version to reuse when the caller already
|
|
1131
|
+
has one.
|
|
1132
|
+
|
|
1133
|
+
Returns:
|
|
1134
|
+
`True` when pre-release versions should be considered.
|
|
1135
|
+
"""
|
|
1136
|
+
if include_prereleases is not None:
|
|
1137
|
+
return include_prereleases
|
|
1138
|
+
if installed is None:
|
|
1139
|
+
try:
|
|
1140
|
+
installed = _parse_version(__version__)
|
|
1141
|
+
except InvalidVersion:
|
|
1142
|
+
logger.warning(
|
|
1143
|
+
"Installed version %r is not PEP 440 compliant; "
|
|
1144
|
+
"defaulting to stable-only upgrades",
|
|
1145
|
+
__version__,
|
|
1146
|
+
)
|
|
1147
|
+
return False
|
|
1148
|
+
return installed.is_prerelease
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def detect_install_method() -> InstallMethod:
|
|
1152
|
+
"""Detect how `deepagents-code` was installed.
|
|
1153
|
+
|
|
1154
|
+
Checks `sys.prefix` against known paths for uv and Homebrew.
|
|
1155
|
+
|
|
1156
|
+
Returns:
|
|
1157
|
+
The detected install method: `'uv'`, `'brew'`, `'other'`, or `'unknown'`
|
|
1158
|
+
(editable/dev installs).
|
|
1159
|
+
"""
|
|
1160
|
+
from deepagents_code.config import _is_editable_install
|
|
1161
|
+
|
|
1162
|
+
prefix = sys.prefix
|
|
1163
|
+
# uv tool installs live under ~/.local/share/uv/tools/
|
|
1164
|
+
if "/uv/tools/" in prefix or "\\uv\\tools\\" in prefix:
|
|
1165
|
+
return "uv"
|
|
1166
|
+
# Homebrew prefixes
|
|
1167
|
+
if any(
|
|
1168
|
+
prefix.startswith(p)
|
|
1169
|
+
for p in ("/opt/homebrew", "/usr/local/Cellar", "/home/linuxbrew")
|
|
1170
|
+
):
|
|
1171
|
+
return "brew"
|
|
1172
|
+
# Editable / dev installs — don't auto-upgrade
|
|
1173
|
+
if _is_editable_install():
|
|
1174
|
+
return "unknown"
|
|
1175
|
+
return "other"
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def upgrade_command(
|
|
1179
|
+
method: InstallMethod | None = None,
|
|
1180
|
+
*,
|
|
1181
|
+
include_prereleases: bool | None = None,
|
|
1182
|
+
version: str | None = None,
|
|
1183
|
+
) -> str:
|
|
1184
|
+
"""Return the shell command to upgrade `deepagents-code`.
|
|
1185
|
+
|
|
1186
|
+
Falls back to the documented uv command for display-only guidance.
|
|
1187
|
+
|
|
1188
|
+
Args:
|
|
1189
|
+
method: Install method override.
|
|
1190
|
+
|
|
1191
|
+
Auto-detected if `None`.
|
|
1192
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
1193
|
+
`None`, follows the installed version's channel. When `True`,
|
|
1194
|
+
returns the uv pre-release command regardless of `method`, since
|
|
1195
|
+
only uv can be steered onto the pre-release channel.
|
|
1196
|
+
version: Optional exact `deepagents-code` version pin for uv guidance.
|
|
1197
|
+
"""
|
|
1198
|
+
include_prereleases = _resolve_include_prereleases(include_prereleases)
|
|
1199
|
+
if version is not None:
|
|
1200
|
+
requirement = _dcode_extras_requirement((), version=version)
|
|
1201
|
+
cmd = f"uv tool install -U {requirement}"
|
|
1202
|
+
if include_prereleases:
|
|
1203
|
+
cmd += " --prerelease allow"
|
|
1204
|
+
return cmd
|
|
1205
|
+
if include_prereleases:
|
|
1206
|
+
return f"{FALLBACK_UPGRADE_COMMAND} --prerelease allow"
|
|
1207
|
+
if method is None:
|
|
1208
|
+
method = detect_install_method()
|
|
1209
|
+
base = _UPGRADE_COMMANDS.get(method, FALLBACK_UPGRADE_COMMAND)
|
|
1210
|
+
return base
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def prerelease_upgrade_supported(
|
|
1214
|
+
method: InstallMethod | None = None,
|
|
1215
|
+
) -> tuple[bool, str | None]:
|
|
1216
|
+
"""Return whether pre-release upgrades are supported for the install method.
|
|
1217
|
+
|
|
1218
|
+
Pre-release channel switching is only safe for `uv tool` installs, where
|
|
1219
|
+
`uv tool upgrade --prerelease allow` re-resolves against the pre-release
|
|
1220
|
+
feed. Other package managers can't be steered onto that channel, so callers
|
|
1221
|
+
should refuse before promising an upgrade.
|
|
1222
|
+
|
|
1223
|
+
Args:
|
|
1224
|
+
method: Install method override.
|
|
1225
|
+
|
|
1226
|
+
Auto-detected if `None`.
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
A `(supported, reason)` tuple. `reason` is `None` when supported, else a
|
|
1230
|
+
user-facing explanation of why the pre-release upgrade is refused.
|
|
1231
|
+
"""
|
|
1232
|
+
if method is None:
|
|
1233
|
+
method = detect_install_method()
|
|
1234
|
+
if method != "uv":
|
|
1235
|
+
return False, _PRERELEASE_UNSUPPORTED_MESSAGE
|
|
1236
|
+
return True, None
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
_DEPENDENCY_REFRESH_UNSUPPORTED: dict[InstallMethod, str] = {
|
|
1240
|
+
"unknown": "Editable install detected — skipping dependency refresh.",
|
|
1241
|
+
"brew": (
|
|
1242
|
+
"Homebrew install detected — dependency-only refresh is not "
|
|
1243
|
+
f"supported without upgrading {DISTRIBUTION_NAME}."
|
|
1244
|
+
),
|
|
1245
|
+
"other": (
|
|
1246
|
+
"Unsupported install method detected — cannot refresh dependencies "
|
|
1247
|
+
"without knowing which environment provides `dcode`."
|
|
1248
|
+
),
|
|
1249
|
+
}
|
|
1250
|
+
"""Why each non-uv install method can't do a dependency-only refresh."""
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def dependency_refresh_supported(
|
|
1254
|
+
method: InstallMethod | None = None,
|
|
1255
|
+
) -> tuple[bool, str | None]:
|
|
1256
|
+
"""Return whether a dependency-only refresh is possible for the install.
|
|
1257
|
+
|
|
1258
|
+
A dependency refresh reinstalls the *current* `deepagents-code` version with
|
|
1259
|
+
upgraded dependency resolution (`uv tool install -U deepagents-code==<v>`).
|
|
1260
|
+
Only uv-managed installs can express that without crossing to a newer app
|
|
1261
|
+
version, so callers should refuse before prompting or shelling out. This is
|
|
1262
|
+
the single source of truth for both the gate in the TUI and the refusal in
|
|
1263
|
+
`perform_dependency_refresh`.
|
|
1264
|
+
|
|
1265
|
+
Args:
|
|
1266
|
+
method: Install method override. Auto-detected if `None`.
|
|
1267
|
+
|
|
1268
|
+
Returns:
|
|
1269
|
+
A `(supported, reason)` tuple. `reason` is `None` when supported, else a
|
|
1270
|
+
user-facing explanation of why the refresh is refused.
|
|
1271
|
+
"""
|
|
1272
|
+
if method is None:
|
|
1273
|
+
method = detect_install_method()
|
|
1274
|
+
if method == "uv":
|
|
1275
|
+
return True, None
|
|
1276
|
+
return False, _DEPENDENCY_REFRESH_UNSUPPORTED[method]
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
@dataclass(frozen=True)
|
|
1280
|
+
class ShadowedDcode:
|
|
1281
|
+
"""A different dcode entry point is winning on PATH than the one we upgraded.
|
|
1282
|
+
|
|
1283
|
+
Returned by `detect_shadowed_dcode` after a successful upgrade so the TUI can
|
|
1284
|
+
warn the user that re-launching will pick up the wrong binary. The most
|
|
1285
|
+
common cause is a pre-uv install (e.g. a leftover from a previous
|
|
1286
|
+
`pipx`/`pip`-based install) earlier on `PATH` than the uv tool shims.
|
|
1287
|
+
|
|
1288
|
+
A frozen dataclass rather than a `NamedTuple` (unlike the sibling
|
|
1289
|
+
`DependencyChange`) so `__post_init__` can enforce the conflict invariant
|
|
1290
|
+
the type's name promises: an instance only exists when there genuinely is
|
|
1291
|
+
a shadow. The producer already guarantees this, so the check is defensive
|
|
1292
|
+
against future direct construction, not a runtime gate on the hot path.
|
|
1293
|
+
"""
|
|
1294
|
+
|
|
1295
|
+
shadowing_bin: Path
|
|
1296
|
+
"""Absolute path to the `dcode` (or `deepagents-code`) binary the user's
|
|
1297
|
+
`PATH` currently resolves first — the file their next `dcode` will run.
|
|
1298
|
+
|
|
1299
|
+
Reported as the un-followed `shutil.which` result rather than its symlink
|
|
1300
|
+
target, since that's the file the user needs to either delete or demote
|
|
1301
|
+
on `PATH`.
|
|
1302
|
+
"""
|
|
1303
|
+
|
|
1304
|
+
upgraded_bin_dir: Path
|
|
1305
|
+
"""Absolute path to the bin directory uv installed the upgraded shim into.
|
|
1306
|
+
|
|
1307
|
+
Resolved via uv's documented executable-directory precedence (see
|
|
1308
|
+
`_uv_tool_bin_dir`).
|
|
1309
|
+
"""
|
|
1310
|
+
|
|
1311
|
+
def __post_init__(self) -> None:
|
|
1312
|
+
"""Reject a non-conflict instance — the type's namesake invariant.
|
|
1313
|
+
|
|
1314
|
+
If the shadowing binary already lives in the upgraded bin dir there is
|
|
1315
|
+
no shadow, and a warning built from it would tell the user a binary
|
|
1316
|
+
shadows itself. `detect_shadowed_dcode` returns `None` in that case, so
|
|
1317
|
+
this only fires on a misconstructed instance.
|
|
1318
|
+
|
|
1319
|
+
Raises:
|
|
1320
|
+
ValueError: If `shadowing_bin` already resides in `upgraded_bin_dir`.
|
|
1321
|
+
"""
|
|
1322
|
+
if self.shadowing_bin.parent == self.upgraded_bin_dir:
|
|
1323
|
+
msg = (
|
|
1324
|
+
f"ShadowedDcode requires a real shadow, but {self.shadowing_bin} "
|
|
1325
|
+
f"already resides in the upgraded bin dir {self.upgraded_bin_dir}"
|
|
1326
|
+
)
|
|
1327
|
+
raise ValueError(msg)
|
|
1328
|
+
|
|
1329
|
+
@property
|
|
1330
|
+
def upgraded_bin(self) -> Path:
|
|
1331
|
+
"""Absolute path to the upgraded `dcode` shim uv installed.
|
|
1332
|
+
|
|
1333
|
+
Keeps the `dcode` entry-point name owned by the type rather than
|
|
1334
|
+
re-derived at each call site (mirrors `DependencyChange.kind`).
|
|
1335
|
+
"""
|
|
1336
|
+
return self.upgraded_bin_dir / DISTRIBUTION_NAME
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _uv_tool_bin_dir() -> Path | None:
|
|
1340
|
+
"""Return the bin directory uv installed the running `dcode` shim into.
|
|
1341
|
+
|
|
1342
|
+
Mirrors uv's documented executable-directory precedence so a custom
|
|
1343
|
+
layout (e.g. `XDG_BIN_HOME` set on Linux) is compared against the same
|
|
1344
|
+
directory uv would install into. Following uv's reference at
|
|
1345
|
+
https://docs.astral.sh/uv/reference/storage/#executable-directory:
|
|
1346
|
+
|
|
1347
|
+
The precedence (single, unbranched code path): `UV_TOOL_BIN_DIR` →
|
|
1348
|
+
`XDG_BIN_HOME` → `$XDG_DATA_HOME/../bin` → the final `.local/bin` under
|
|
1349
|
+
the home directory. The last candidate is `Path.home() / ".local" / "bin"`,
|
|
1350
|
+
which `pathlib` resolves per-platform — `$HOME/.local/bin` on Unix and
|
|
1351
|
+
`%USERPROFILE%/.local/bin` on Windows — so one expression satisfies uv's
|
|
1352
|
+
documented fallback on both without an `os.name` branch here.
|
|
1353
|
+
|
|
1354
|
+
The first candidate that exists as a directory wins; an existing but
|
|
1355
|
+
unusable candidate (read failures, race) is skipped so a transient
|
|
1356
|
+
glitch doesn't downgrade the answer to a less-preferred path.
|
|
1357
|
+
|
|
1358
|
+
Returns:
|
|
1359
|
+
The absolute, resolved bin directory, or `None` when no candidate
|
|
1360
|
+
exists (e.g. a CI install that never created `~/.local/bin`).
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
def _from_env(name: str) -> Path | None:
|
|
1364
|
+
raw = os.environ.get(name)
|
|
1365
|
+
return Path(raw).expanduser() if raw else None
|
|
1366
|
+
|
|
1367
|
+
home = Path.home()
|
|
1368
|
+
xdg_data_home_str = os.environ.get("XDG_DATA_HOME")
|
|
1369
|
+
xdg_data_home = (
|
|
1370
|
+
Path(xdg_data_home_str).expanduser()
|
|
1371
|
+
if xdg_data_home_str
|
|
1372
|
+
else home / ".local" / "share"
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# Build candidates in uv's documented precedence order. None entries
|
|
1376
|
+
# (env var unset) are filtered out before iteration so each remaining
|
|
1377
|
+
# candidate is a real path.
|
|
1378
|
+
candidates: list[Path | None] = [
|
|
1379
|
+
_from_env("UV_TOOL_BIN_DIR"),
|
|
1380
|
+
_from_env("XDG_BIN_HOME"),
|
|
1381
|
+
# `$XDG_DATA_HOME/../bin` — uv's documented intermediate fallback.
|
|
1382
|
+
# Falls back to the spec default (`~/.local/share`) when the env
|
|
1383
|
+
# var is unset; the resulting `~/.local/share/../bin` = `~/.local/bin`
|
|
1384
|
+
# matches the final fallback below, which is fine — only the first
|
|
1385
|
+
# match wins.
|
|
1386
|
+
xdg_data_home.parent / "bin",
|
|
1387
|
+
home / ".local" / "bin",
|
|
1388
|
+
]
|
|
1389
|
+
for candidate in candidates:
|
|
1390
|
+
if candidate is None:
|
|
1391
|
+
continue
|
|
1392
|
+
try:
|
|
1393
|
+
resolved = candidate.resolve()
|
|
1394
|
+
except OSError:
|
|
1395
|
+
logger.debug(
|
|
1396
|
+
"Could not resolve uv tool bin dir candidate %s",
|
|
1397
|
+
candidate,
|
|
1398
|
+
exc_info=True,
|
|
1399
|
+
)
|
|
1400
|
+
continue
|
|
1401
|
+
if resolved.is_dir():
|
|
1402
|
+
return resolved
|
|
1403
|
+
return None
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def detect_shadowed_dcode() -> ShadowedDcode | None:
|
|
1407
|
+
"""Return the shadowing dcode entry point on the user's PATH, if any.
|
|
1408
|
+
|
|
1409
|
+
After a successful `uv tool upgrade`, the upgraded binary only takes effect
|
|
1410
|
+
on the next launch if the user's `PATH` resolves to uv's tool bin dir for
|
|
1411
|
+
`dcode` (and `deepagents-code`). A pre-uv install earlier on `PATH` will
|
|
1412
|
+
silently win and report the old version, which looks like "the upgrade
|
|
1413
|
+
didn't work" to the user.
|
|
1414
|
+
|
|
1415
|
+
This compares each supported console script against uv's tool bin dir. A
|
|
1416
|
+
mismatch means a different binary will run next launch for that entry point.
|
|
1417
|
+
|
|
1418
|
+
Caveat: a `dcode` symlink that lives in some unrelated bin dir but
|
|
1419
|
+
points *into* the upgraded tool venv (e.g. a manually-created
|
|
1420
|
+
convenience symlink) is reported as shadowing even though the next
|
|
1421
|
+
launch would actually run the upgraded entry point. Comparing
|
|
1422
|
+
directories rather than resolved targets is intentional — see the
|
|
1423
|
+
inline note below for why — and this edge is rare enough that we
|
|
1424
|
+
accept a benign false positive over a class of false negatives.
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
A `ShadowedDcode` describing the conflict, or `None` when there is no
|
|
1428
|
+
shadowing binary (the common case) or when detection is not
|
|
1429
|
+
applicable (non-uv install, uv bin dir unknown, no supported entry
|
|
1430
|
+
point on `PATH` at all).
|
|
1431
|
+
"""
|
|
1432
|
+
if detect_install_method() != "uv":
|
|
1433
|
+
return None
|
|
1434
|
+
upgraded_bin_dir = _uv_tool_bin_dir()
|
|
1435
|
+
if upgraded_bin_dir is None:
|
|
1436
|
+
return None
|
|
1437
|
+
# Check every supported entry point. One healthy command name does not
|
|
1438
|
+
# prove another command name cannot still be shadowed earlier on PATH.
|
|
1439
|
+
for name in (DISTRIBUTION_NAME,):
|
|
1440
|
+
resolved = shutil.which(name)
|
|
1441
|
+
if resolved is None:
|
|
1442
|
+
continue
|
|
1443
|
+
# Compare the *PATH-entry directory* against uv's bin dir, NOT the
|
|
1444
|
+
# symlink target. uv exposes its tool entry points as symlinks under
|
|
1445
|
+
# the user's bin dir (e.g. `~/.local/bin/dcode` -> the tool venv at
|
|
1446
|
+
# `~/.local/share/uv/tools/deepagents-code/bin/dcode`). Following the
|
|
1447
|
+
# link would make every healthy uv install look shadowed, because the
|
|
1448
|
+
# resolved parent is the tool venv's bin dir rather than the
|
|
1449
|
+
# PATH-visible one. Take the parent of the un-followed `which`
|
|
1450
|
+
# result so we answer the question we actually care about: "is uv's
|
|
1451
|
+
# bin dir what PATH resolves to?" `Path(...).parent` does not follow
|
|
1452
|
+
# the file's symlink. Only the directory is canonicalized, so
|
|
1453
|
+
# benign filesystem aliases (case folding, /private/var vs /var on
|
|
1454
|
+
# macOS, mount-point synonyms) still compare equal.
|
|
1455
|
+
path_dir = Path(resolved).parent
|
|
1456
|
+
try:
|
|
1457
|
+
canonical_path_dir = path_dir.resolve()
|
|
1458
|
+
except OSError:
|
|
1459
|
+
# Couldn't canonicalize the PATH-entry directory (e.g. a stale
|
|
1460
|
+
# symlink, a vanished mount). Returning `None` here would
|
|
1461
|
+
# silently hide a real shadow, so continue to the next candidate
|
|
1462
|
+
# name if any; if this was the last (`deepagents-code`), the loop
|
|
1463
|
+
# falls through to `None` — an indeterminate result we'd rather
|
|
1464
|
+
# surface to a developer than mask, hence `warning`, not `debug`.
|
|
1465
|
+
logger.warning(
|
|
1466
|
+
"Could not resolve PATH directory for %s at %s",
|
|
1467
|
+
name,
|
|
1468
|
+
path_dir,
|
|
1469
|
+
exc_info=True,
|
|
1470
|
+
)
|
|
1471
|
+
continue
|
|
1472
|
+
if canonical_path_dir == upgraded_bin_dir:
|
|
1473
|
+
# This entry point resolves to the directory uv just wrote into.
|
|
1474
|
+
# Keep checking the other supported entry point before declaring
|
|
1475
|
+
# there is no shadow.
|
|
1476
|
+
continue
|
|
1477
|
+
return ShadowedDcode(
|
|
1478
|
+
shadowing_bin=Path(resolved),
|
|
1479
|
+
upgraded_bin_dir=upgraded_bin_dir,
|
|
1480
|
+
)
|
|
1481
|
+
return None
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def detect_shadowed_dcode_safe() -> ShadowedDcode | None:
|
|
1485
|
+
"""Best-effort `detect_shadowed_dcode` that never raises.
|
|
1486
|
+
|
|
1487
|
+
The shadow check only ever runs to decorate an already-successful upgrade,
|
|
1488
|
+
so a defect in detection — or an unexpected error escaping the narrow
|
|
1489
|
+
`OSError` guards inside `detect_shadowed_dcode` — must not turn a working
|
|
1490
|
+
upgrade into a user-facing failure. Any unexpected exception is logged and
|
|
1491
|
+
treated as "no shadow detected", matching the fail-open bias the detector
|
|
1492
|
+
already applies internally.
|
|
1493
|
+
|
|
1494
|
+
Returns:
|
|
1495
|
+
Whatever `detect_shadowed_dcode` returns, or `None` if it raised.
|
|
1496
|
+
"""
|
|
1497
|
+
try:
|
|
1498
|
+
return detect_shadowed_dcode()
|
|
1499
|
+
except Exception:
|
|
1500
|
+
logger.warning("Shadow detection failed after upgrade", exc_info=True)
|
|
1501
|
+
return None
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def format_shadowed_dcode_warning(shadow: ShadowedDcode) -> str:
|
|
1505
|
+
"""Render a user-facing warning for a shadowed-dcode situation.
|
|
1506
|
+
|
|
1507
|
+
Shared by the `/update` slash command, the update-notification "Install
|
|
1508
|
+
now" action, and the pre-launch auto-update path so the wording stays
|
|
1509
|
+
consistent.
|
|
1510
|
+
|
|
1511
|
+
Args:
|
|
1512
|
+
shadow: The shadowing-binary description returned by
|
|
1513
|
+
`detect_shadowed_dcode`.
|
|
1514
|
+
|
|
1515
|
+
Returns:
|
|
1516
|
+
A plain-text, multi-line warning suitable for either the TUI message
|
|
1517
|
+
stream or a Rich `console.print`.
|
|
1518
|
+
"""
|
|
1519
|
+
fix_command = format_shadowed_dcode_fix_command(shadow)
|
|
1520
|
+
indented_command = fix_command.replace("\n", "\n ")
|
|
1521
|
+
return (
|
|
1522
|
+
f"Update installed, but another `{DISTRIBUTION_NAME}` is earlier on your PATH and "
|
|
1523
|
+
"will keep running the old version on relaunch:\n"
|
|
1524
|
+
f" Shadowing binary: {shadow.shadowing_bin}\n"
|
|
1525
|
+
f" Upgraded shim: {shadow.upgraded_bin}\n"
|
|
1526
|
+
f"After closing {DISTRIBUTION_NAME}, run this to make the upgraded shim win in this "
|
|
1527
|
+
"terminal:\n"
|
|
1528
|
+
f" {indented_command}\n"
|
|
1529
|
+
f"Then relaunch {DISTRIBUTION_NAME}. To make the fix permanent, add the PATH change "
|
|
1530
|
+
f"to your shell profile, or uninstall the older {DISTRIBUTION_NAME} if you no longer "
|
|
1531
|
+
"need it."
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def format_shadowed_dcode_fix_command(shadow: ShadowedDcode) -> str:
|
|
1536
|
+
"""Return a session-scoped shell command to prefer the upgraded shim.
|
|
1537
|
+
|
|
1538
|
+
The command targets the shell that matches the current platform: PowerShell
|
|
1539
|
+
on Windows (where `_uv_tool_bin_dir` can resolve `%USERPROFILE%/.local/bin`
|
|
1540
|
+
and `export`/`hash` are not valid), and POSIX `sh`/`bash`/`zsh` elsewhere.
|
|
1541
|
+
|
|
1542
|
+
Args:
|
|
1543
|
+
shadow: The shadowing-binary description returned by
|
|
1544
|
+
`detect_shadowed_dcode`.
|
|
1545
|
+
|
|
1546
|
+
Returns:
|
|
1547
|
+
A copy-pasteable shell command that updates only the current terminal
|
|
1548
|
+
session and, on POSIX, clears the shell's command-path cache.
|
|
1549
|
+
"""
|
|
1550
|
+
bin_dir = str(shadow.upgraded_bin_dir)
|
|
1551
|
+
if os.name == "nt":
|
|
1552
|
+
# PowerShell, the default Windows shell. Single-quote the literal path so
|
|
1553
|
+
# `$`, `$()`, and backticks in directory names are not expanded, then
|
|
1554
|
+
# concatenate the live session PATH outside the literal. No cache flush is
|
|
1555
|
+
# needed — PowerShell resolves executables per invocation rather than
|
|
1556
|
+
# caching like POSIX shells' `hash`.
|
|
1557
|
+
quoted = bin_dir.replace("'", "''")
|
|
1558
|
+
return f"$env:PATH = '{quoted};' + $env:PATH"
|
|
1559
|
+
quoted = shlex.quote(bin_dir)
|
|
1560
|
+
return f"export PATH={quoted}:$PATH\nhash -r 2>/dev/null || true"
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def cleanup_update_logs(
|
|
1564
|
+
*,
|
|
1565
|
+
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
|
|
1566
|
+
max_files: int = UPDATE_LOG_MAX_FILES,
|
|
1567
|
+
) -> None:
|
|
1568
|
+
"""Remove old update logs while preserving the newest recent logs.
|
|
1569
|
+
|
|
1570
|
+
Args:
|
|
1571
|
+
retention_days: Maximum age in days to keep.
|
|
1572
|
+
max_files: Maximum number of newest log files to keep.
|
|
1573
|
+
"""
|
|
1574
|
+
try:
|
|
1575
|
+
if not UPDATE_LOG_DIR.exists():
|
|
1576
|
+
return
|
|
1577
|
+
logs = sorted(
|
|
1578
|
+
(
|
|
1579
|
+
(p, p.stat().st_mtime)
|
|
1580
|
+
for p in UPDATE_LOG_DIR.glob("*.log")
|
|
1581
|
+
if p.is_file()
|
|
1582
|
+
),
|
|
1583
|
+
key=operator.itemgetter(1),
|
|
1584
|
+
reverse=True,
|
|
1585
|
+
)
|
|
1586
|
+
cutoff = time.time() - (retention_days * 86_400)
|
|
1587
|
+
for idx, (path, mtime) in enumerate(logs):
|
|
1588
|
+
if idx >= max_files or mtime < cutoff:
|
|
1589
|
+
path.unlink(missing_ok=True)
|
|
1590
|
+
except OSError:
|
|
1591
|
+
logger.debug("Failed to clean up update logs", exc_info=True)
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def create_update_log_path() -> Path:
|
|
1595
|
+
"""Return a new timestamped update log path and clean stale logs."""
|
|
1596
|
+
cleanup_update_logs()
|
|
1597
|
+
stamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")
|
|
1598
|
+
return UPDATE_LOG_DIR / f"{stamp}-update.log"
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
async def _emit_progress(callback: UpgradeProgressCallback | None, line: str) -> None:
|
|
1602
|
+
"""Send a progress line to *callback*, supporting sync or async callbacks."""
|
|
1603
|
+
if callback is None:
|
|
1604
|
+
return
|
|
1605
|
+
result = callback(line)
|
|
1606
|
+
if isinstance(result, Awaitable):
|
|
1607
|
+
await result
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
async def _read_stream(
|
|
1611
|
+
stream: asyncio.StreamReader,
|
|
1612
|
+
*,
|
|
1613
|
+
lines: list[str],
|
|
1614
|
+
log_file: TextIO | None,
|
|
1615
|
+
progress: UpgradeProgressCallback | None,
|
|
1616
|
+
) -> None:
|
|
1617
|
+
"""Read subprocess output, append it to the log file, and emit progress."""
|
|
1618
|
+
while True:
|
|
1619
|
+
raw = await stream.readline()
|
|
1620
|
+
if not raw:
|
|
1621
|
+
return
|
|
1622
|
+
line = raw.decode(errors="replace").rstrip("\n")
|
|
1623
|
+
lines.append(line)
|
|
1624
|
+
if log_file is not None:
|
|
1625
|
+
with suppress(OSError):
|
|
1626
|
+
log_file.write(f"{line}\n")
|
|
1627
|
+
log_file.flush()
|
|
1628
|
+
await _emit_progress(progress, line)
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
async def _terminate_install_process(proc: asyncio.subprocess.Process) -> None:
|
|
1632
|
+
"""Best-effort kill of an install subprocess and its descendants.
|
|
1633
|
+
|
|
1634
|
+
On POSIX the sole caller starts the child in its own session
|
|
1635
|
+
(`start_new_session=True`), so `proc.pid` doubles as the process-group id
|
|
1636
|
+
and `killpg` reaps descendants too; do not call this on a process not
|
|
1637
|
+
started that way or it would signal the caller's own group.
|
|
1638
|
+
|
|
1639
|
+
This is teardown that runs *under* a timeout or cancellation, so it must
|
|
1640
|
+
never raise — a stray error here would mask the failure it is cleaning up
|
|
1641
|
+
after. Every step therefore swallows benign races (a process that already
|
|
1642
|
+
exited, a group we cannot signal) and the final reap is time-bounded so an
|
|
1643
|
+
unreapable child cannot hang startup before the TUI.
|
|
1644
|
+
"""
|
|
1645
|
+
if os.name == "posix":
|
|
1646
|
+
try:
|
|
1647
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
1648
|
+
except ProcessLookupError:
|
|
1649
|
+
pass
|
|
1650
|
+
except OSError:
|
|
1651
|
+
# e.g. EPERM if the group contains a privileged descendant; fall
|
|
1652
|
+
# back to killing the direct child so at least it is reaped.
|
|
1653
|
+
with suppress(OSError):
|
|
1654
|
+
proc.kill()
|
|
1655
|
+
elif proc.returncode is None:
|
|
1656
|
+
with suppress(OSError):
|
|
1657
|
+
proc.kill()
|
|
1658
|
+
with suppress(OSError, TimeoutError):
|
|
1659
|
+
await asyncio.wait_for(proc.wait(), timeout=_TERMINATE_WAIT_TIMEOUT)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
async def _run_install_subprocess(
|
|
1663
|
+
cmd: str,
|
|
1664
|
+
*,
|
|
1665
|
+
progress: UpgradeProgressCallback | None,
|
|
1666
|
+
log_path: Path | None,
|
|
1667
|
+
) -> tuple[bool, str]:
|
|
1668
|
+
"""Run a shell command, streaming stdout/stderr to *progress* and a log file.
|
|
1669
|
+
|
|
1670
|
+
Shared subprocess plumbing for `perform_upgrade` and
|
|
1671
|
+
`perform_install_extra`. Returns `(success, combined_output)` where
|
|
1672
|
+
*combined_output* is the concatenated stdout+stderr, stripped.
|
|
1673
|
+
|
|
1674
|
+
On timeout or `OSError`, the process is killed and a synthetic error
|
|
1675
|
+
line is emitted both to the log and via *progress*. The wall-clock cap
|
|
1676
|
+
is `_UPGRADE_TIMEOUT`.
|
|
1677
|
+
|
|
1678
|
+
Args:
|
|
1679
|
+
cmd: Shell command to execute.
|
|
1680
|
+
progress: Optional callback invoked for each output line.
|
|
1681
|
+
log_path: Optional path to persist command output. Falls back to a
|
|
1682
|
+
fresh `create_update_log_path()` when `None`.
|
|
1683
|
+
|
|
1684
|
+
Returns:
|
|
1685
|
+
`(success, output)` — *success* is `True` iff the subprocess exited 0.
|
|
1686
|
+
|
|
1687
|
+
Raises:
|
|
1688
|
+
asyncio.CancelledError: If the calling task is cancelled.
|
|
1689
|
+
"""
|
|
1690
|
+
timeout = _UPGRADE_TIMEOUT
|
|
1691
|
+
if log_path is None:
|
|
1692
|
+
log_path = create_update_log_path()
|
|
1693
|
+
|
|
1694
|
+
output_lines: list[str] = []
|
|
1695
|
+
proc: asyncio.subprocess.Process | None = None
|
|
1696
|
+
log_file: TextIO | None = None
|
|
1697
|
+
try:
|
|
1698
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1699
|
+
log_file = log_path.open("w", encoding="utf-8")
|
|
1700
|
+
log_file.write(f"$ {cmd}\n")
|
|
1701
|
+
log_file.flush()
|
|
1702
|
+
except OSError:
|
|
1703
|
+
logger.warning(
|
|
1704
|
+
"Could not create install log at %s; subprocess output will not be "
|
|
1705
|
+
"persisted to disk",
|
|
1706
|
+
log_path,
|
|
1707
|
+
exc_info=True,
|
|
1708
|
+
)
|
|
1709
|
+
log_file = None
|
|
1710
|
+
|
|
1711
|
+
try:
|
|
1712
|
+
if os.name == "posix":
|
|
1713
|
+
proc = await asyncio.create_subprocess_shell(
|
|
1714
|
+
cmd,
|
|
1715
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1716
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1717
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1718
|
+
start_new_session=True,
|
|
1719
|
+
)
|
|
1720
|
+
else:
|
|
1721
|
+
proc = await asyncio.create_subprocess_shell(
|
|
1722
|
+
cmd,
|
|
1723
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1724
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1725
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1726
|
+
)
|
|
1727
|
+
await asyncio.wait_for(
|
|
1728
|
+
asyncio.gather(
|
|
1729
|
+
_read_stream(
|
|
1730
|
+
proc.stdout, # ty: ignore[invalid-argument-type]
|
|
1731
|
+
lines=output_lines,
|
|
1732
|
+
log_file=log_file,
|
|
1733
|
+
progress=progress,
|
|
1734
|
+
),
|
|
1735
|
+
_read_stream(
|
|
1736
|
+
proc.stderr, # ty: ignore[invalid-argument-type]
|
|
1737
|
+
lines=output_lines,
|
|
1738
|
+
log_file=log_file,
|
|
1739
|
+
progress=progress,
|
|
1740
|
+
),
|
|
1741
|
+
proc.wait(),
|
|
1742
|
+
),
|
|
1743
|
+
timeout=timeout,
|
|
1744
|
+
)
|
|
1745
|
+
except TimeoutError:
|
|
1746
|
+
if proc is not None:
|
|
1747
|
+
await _terminate_install_process(proc)
|
|
1748
|
+
msg = f"Command timed out after {timeout}s: {cmd}"
|
|
1749
|
+
if log_file is not None:
|
|
1750
|
+
with suppress(OSError):
|
|
1751
|
+
log_file.write(f"{msg}\n")
|
|
1752
|
+
log_file.close()
|
|
1753
|
+
await _emit_progress(progress, msg)
|
|
1754
|
+
logger.warning(msg)
|
|
1755
|
+
return False, msg
|
|
1756
|
+
except asyncio.CancelledError:
|
|
1757
|
+
if proc is not None:
|
|
1758
|
+
await _terminate_install_process(proc)
|
|
1759
|
+
if log_file is not None:
|
|
1760
|
+
with suppress(OSError):
|
|
1761
|
+
log_file.close()
|
|
1762
|
+
raise
|
|
1763
|
+
except OSError as exc:
|
|
1764
|
+
if log_file is not None:
|
|
1765
|
+
with suppress(OSError):
|
|
1766
|
+
log_file.close()
|
|
1767
|
+
logger.warning("Failed to execute command: %s", cmd, exc_info=True)
|
|
1768
|
+
return False, f"Failed to execute: {cmd}\n{type(exc).__name__}: {exc}"
|
|
1769
|
+
|
|
1770
|
+
if log_file is not None:
|
|
1771
|
+
with suppress(OSError):
|
|
1772
|
+
log_file.close()
|
|
1773
|
+
output = "\n".join(output_lines).strip()
|
|
1774
|
+
if proc.returncode == 0:
|
|
1775
|
+
return True, output
|
|
1776
|
+
logger.warning(
|
|
1777
|
+
"Command exited with code %d: %s\n%s",
|
|
1778
|
+
proc.returncode,
|
|
1779
|
+
cmd,
|
|
1780
|
+
output,
|
|
1781
|
+
)
|
|
1782
|
+
return False, output
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
async def perform_upgrade(
|
|
1786
|
+
*,
|
|
1787
|
+
progress: UpgradeProgressCallback | None = None,
|
|
1788
|
+
log_path: Path | None = None,
|
|
1789
|
+
include_prereleases: bool | None = None,
|
|
1790
|
+
target_version: str | None = None,
|
|
1791
|
+
) -> tuple[bool, str]:
|
|
1792
|
+
"""Attempt to upgrade `deepagents-code` using the detected install method.
|
|
1793
|
+
|
|
1794
|
+
Only tries the detected method — does not fall back to other package
|
|
1795
|
+
managers to avoid cross-environment contamination.
|
|
1796
|
+
|
|
1797
|
+
Args:
|
|
1798
|
+
progress: Optional callback invoked for each output line.
|
|
1799
|
+
log_path: Optional path to persist command output.
|
|
1800
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
1801
|
+
`None`, follows the installed version's channel and the target
|
|
1802
|
+
release's dependency metadata. Pre-release upgrades require the uv
|
|
1803
|
+
install method; returns failure otherwise.
|
|
1804
|
+
target_version: Release version being installed, used to detect stable
|
|
1805
|
+
dcode releases that intentionally depend on pre-release packages.
|
|
1806
|
+
|
|
1807
|
+
Returns:
|
|
1808
|
+
`(success, output)` — *output* is the combined stdout/stderr.
|
|
1809
|
+
"""
|
|
1810
|
+
method = detect_install_method()
|
|
1811
|
+
if method == "unknown":
|
|
1812
|
+
return False, "Editable install detected — skipping auto-update."
|
|
1813
|
+
if method == "other":
|
|
1814
|
+
return False, (
|
|
1815
|
+
"Unsupported install method detected — cannot auto-update without "
|
|
1816
|
+
f"knowing which environment provides `{DISTRIBUTION_NAME}`. Reinstall with "
|
|
1817
|
+
f"`uv tool install -U {DISTRIBUTION_NAME}` or upgrade with the package "
|
|
1818
|
+
"manager originally used for this install."
|
|
1819
|
+
)
|
|
1820
|
+
resolved_include_prereleases = _resolve_include_prereleases(include_prereleases)
|
|
1821
|
+
pin_target_version: str | None = None
|
|
1822
|
+
if (
|
|
1823
|
+
not resolved_include_prereleases
|
|
1824
|
+
and include_prereleases is None
|
|
1825
|
+
and release_requires_prereleases(target_version)
|
|
1826
|
+
):
|
|
1827
|
+
resolved_include_prereleases = True
|
|
1828
|
+
pin_target_version = target_version
|
|
1829
|
+
if resolved_include_prereleases:
|
|
1830
|
+
supported, reason = prerelease_upgrade_supported(method)
|
|
1831
|
+
if not supported:
|
|
1832
|
+
return False, reason or _PRERELEASE_UNSUPPORTED_MESSAGE
|
|
1833
|
+
|
|
1834
|
+
fell_back_to_bare_command = False
|
|
1835
|
+
if method == "uv":
|
|
1836
|
+
# Prefer the receipt-aware `uv tool install -U` builder so installed
|
|
1837
|
+
# extras / `--with` packages survive the upgrade and any stale
|
|
1838
|
+
# `==<version>` pin in the receipt is cleared. Fall back to the bare
|
|
1839
|
+
# display command when extras or receipt introspection fails — the
|
|
1840
|
+
# fallback might drop extras, but a successful unpinned upgrade is
|
|
1841
|
+
# still strictly better than a pinned "upgrade" that quietly stays
|
|
1842
|
+
# on the old version.
|
|
1843
|
+
from deepagents_code.extras_info import ExtrasIntrospectionError
|
|
1844
|
+
|
|
1845
|
+
try:
|
|
1846
|
+
cmd = upgrade_install_command(
|
|
1847
|
+
include_prereleases=resolved_include_prereleases,
|
|
1848
|
+
version=pin_target_version,
|
|
1849
|
+
)
|
|
1850
|
+
except (ExtrasIntrospectionError, ToolRequirementIntrospectionError) as exc:
|
|
1851
|
+
logger.warning(
|
|
1852
|
+
"Could not build receipt-aware uv upgrade command (%s: %s); "
|
|
1853
|
+
"falling back to the bare command. Installed extras may be "
|
|
1854
|
+
"dropped.",
|
|
1855
|
+
type(exc).__name__,
|
|
1856
|
+
exc,
|
|
1857
|
+
)
|
|
1858
|
+
fell_back_to_bare_command = True
|
|
1859
|
+
cmd = upgrade_command(
|
|
1860
|
+
method,
|
|
1861
|
+
include_prereleases=resolved_include_prereleases,
|
|
1862
|
+
version=pin_target_version,
|
|
1863
|
+
)
|
|
1864
|
+
else:
|
|
1865
|
+
cmd = upgrade_command(
|
|
1866
|
+
method,
|
|
1867
|
+
include_prereleases=resolved_include_prereleases,
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
# Skip brew if binary not on PATH
|
|
1871
|
+
if method == "brew" and not shutil.which("brew"):
|
|
1872
|
+
return False, "brew not found on PATH."
|
|
1873
|
+
|
|
1874
|
+
success, output = await _run_install_subprocess(
|
|
1875
|
+
cmd, progress=progress, log_path=log_path
|
|
1876
|
+
)
|
|
1877
|
+
if success and fell_back_to_bare_command:
|
|
1878
|
+
# Surface the dropped-extras caveat only now that the bare upgrade has
|
|
1879
|
+
# actually succeeded. Emitting it before `_run_install_subprocess` ran
|
|
1880
|
+
# would misfire on a failed upgrade — telling the user to re-add extras
|
|
1881
|
+
# for an install that was left untouched. The log line above is
|
|
1882
|
+
# invisible in the TUI, so the progress stream is the user's only window
|
|
1883
|
+
# into this.
|
|
1884
|
+
await _emit_progress(
|
|
1885
|
+
progress,
|
|
1886
|
+
"Note: couldn't read your full install configuration; "
|
|
1887
|
+
"installed extras or extra packages may not carry over. "
|
|
1888
|
+
"Re-add them if a feature stops working after relaunch.",
|
|
1889
|
+
)
|
|
1890
|
+
return success, output
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
async def perform_dependency_refresh(
|
|
1894
|
+
*,
|
|
1895
|
+
progress: UpgradeProgressCallback | None = None,
|
|
1896
|
+
log_path: Path | None = None,
|
|
1897
|
+
include_prereleases: bool | None = None,
|
|
1898
|
+
) -> tuple[bool, str]:
|
|
1899
|
+
"""Refresh dependencies while keeping `deepagents-code` on this version.
|
|
1900
|
+
|
|
1901
|
+
Runs `uv tool install -U deepagents-code==<current>` instead of
|
|
1902
|
+
`uv tool upgrade deepagents-code`, so compatible dependency releases can be
|
|
1903
|
+
picked up without crossing to a newer app version. Only uv-managed installs
|
|
1904
|
+
are supported; other install methods cannot safely express this operation.
|
|
1905
|
+
|
|
1906
|
+
Args:
|
|
1907
|
+
progress: Optional callback invoked for each output line.
|
|
1908
|
+
log_path: Optional path to persist command output.
|
|
1909
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
1910
|
+
`None`, follows the installed version's channel.
|
|
1911
|
+
|
|
1912
|
+
Returns:
|
|
1913
|
+
`(success, output)` — *output* is the combined stdout/stderr, or an
|
|
1914
|
+
explanatory message when the install method is unsupported, `uv` is
|
|
1915
|
+
unavailable, requirement introspection fails, or the subprocess
|
|
1916
|
+
fails or times out.
|
|
1917
|
+
"""
|
|
1918
|
+
supported, reason = dependency_refresh_supported()
|
|
1919
|
+
if not supported:
|
|
1920
|
+
return False, reason or ""
|
|
1921
|
+
if not shutil.which("uv"):
|
|
1922
|
+
return False, "`uv` not found on PATH."
|
|
1923
|
+
|
|
1924
|
+
from deepagents_code.extras_info import ExtrasIntrospectionError
|
|
1925
|
+
|
|
1926
|
+
try:
|
|
1927
|
+
cmd = dependency_refresh_command(
|
|
1928
|
+
include_prereleases=include_prereleases,
|
|
1929
|
+
)
|
|
1930
|
+
except (
|
|
1931
|
+
ExtrasIntrospectionError,
|
|
1932
|
+
ToolRequirementIntrospectionError,
|
|
1933
|
+
ValueError,
|
|
1934
|
+
) as exc:
|
|
1935
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1936
|
+
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
async def perform_dependency_refresh_dry_run(
|
|
1940
|
+
*,
|
|
1941
|
+
progress: UpgradeProgressCallback | None = None,
|
|
1942
|
+
log_path: Path | None = None,
|
|
1943
|
+
include_prereleases: bool | None = None,
|
|
1944
|
+
) -> tuple[bool, str]:
|
|
1945
|
+
"""Resolve a dependency refresh plan without mutating the tool environment.
|
|
1946
|
+
|
|
1947
|
+
`uv tool install` has no `--dry-run`, so this targets the running tool
|
|
1948
|
+
environment with `uv pip install --dry-run --python <sys.executable>`. It
|
|
1949
|
+
uses the same pinned `deepagents-code` requirement, installed extras, and
|
|
1950
|
+
preserved `--with` packages as the real refresh command.
|
|
1951
|
+
|
|
1952
|
+
Args:
|
|
1953
|
+
progress: Optional callback invoked for each output line.
|
|
1954
|
+
log_path: Optional path to persist command output.
|
|
1955
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
1956
|
+
`None`, follows the installed version's channel.
|
|
1957
|
+
|
|
1958
|
+
Returns:
|
|
1959
|
+
`(success, output)` — *output* is the combined stdout/stderr from uv, or
|
|
1960
|
+
an explanatory message when the plan cannot be computed safely.
|
|
1961
|
+
"""
|
|
1962
|
+
supported, reason = dependency_refresh_supported()
|
|
1963
|
+
if not supported:
|
|
1964
|
+
return False, reason or ""
|
|
1965
|
+
if not shutil.which("uv"):
|
|
1966
|
+
return False, "`uv` not found on PATH."
|
|
1967
|
+
|
|
1968
|
+
from deepagents_code.extras_info import ExtrasIntrospectionError
|
|
1969
|
+
|
|
1970
|
+
try:
|
|
1971
|
+
cmd = dependency_refresh_dry_run_command(
|
|
1972
|
+
include_prereleases=include_prereleases,
|
|
1973
|
+
)
|
|
1974
|
+
except (
|
|
1975
|
+
ExtrasIntrospectionError,
|
|
1976
|
+
ToolRequirementIntrospectionError,
|
|
1977
|
+
ValueError,
|
|
1978
|
+
) as exc:
|
|
1979
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1980
|
+
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
class DependencyChange(NamedTuple):
|
|
1984
|
+
"""A single package version change parsed from uv's environment-diff output.
|
|
1985
|
+
|
|
1986
|
+
Emitted by both `uv tool upgrade` and `uv tool install -U` (the
|
|
1987
|
+
dependency-refresh path), so the wording stays command-agnostic. `old` is
|
|
1988
|
+
`None` for a newly added package and `new` is `None` for a removed one; both
|
|
1989
|
+
are set for an in-place version bump. The `(None, None)` state is invalid —
|
|
1990
|
+
see `kind`.
|
|
1991
|
+
"""
|
|
1992
|
+
|
|
1993
|
+
name: str
|
|
1994
|
+
"""Package name exactly as uv reported it in the diff line."""
|
|
1995
|
+
|
|
1996
|
+
old: str | None
|
|
1997
|
+
"""Version before the change; `None` when the package was newly added."""
|
|
1998
|
+
|
|
1999
|
+
new: str | None
|
|
2000
|
+
"""Version after the change; `None` when the package was removed."""
|
|
2001
|
+
|
|
2002
|
+
@property
|
|
2003
|
+
def kind(self) -> Literal["added", "removed", "bumped"]:
|
|
2004
|
+
"""Classify the change from which version sides are present.
|
|
2005
|
+
|
|
2006
|
+
Reading the case from here keeps consumers (e.g.
|
|
2007
|
+
`format_dependency_changes`) from re-deriving it via field truthiness,
|
|
2008
|
+
and turns the meaningless `(None, None)` shape into a hard error instead
|
|
2009
|
+
of silently rendering as a removal.
|
|
2010
|
+
|
|
2011
|
+
Returns:
|
|
2012
|
+
`"added"` when only `new` is set, `"removed"` when only `old` is set,
|
|
2013
|
+
and `"bumped"` when both are set.
|
|
2014
|
+
|
|
2015
|
+
Raises:
|
|
2016
|
+
ValueError: If neither `old` nor `new` is set.
|
|
2017
|
+
"""
|
|
2018
|
+
if self.old is None and self.new is None:
|
|
2019
|
+
msg = (
|
|
2020
|
+
f"DependencyChange {self.name!r} records neither an old nor new version"
|
|
2021
|
+
)
|
|
2022
|
+
raise ValueError(msg)
|
|
2023
|
+
if self.old is None:
|
|
2024
|
+
return "added"
|
|
2025
|
+
if self.new is None:
|
|
2026
|
+
return "removed"
|
|
2027
|
+
return "bumped"
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
_DEP_CHANGE_RE = re.compile(
|
|
2031
|
+
r"^\s*([+-])\s+([A-Za-z0-9._-]+)==([^\s(]+)(?:\s+\(.*\))?\s*$"
|
|
2032
|
+
)
|
|
2033
|
+
"""Matches uv's environment-diff lines, e.g. ` - langchain-openai==1.3.2`.
|
|
2034
|
+
|
|
2035
|
+
The optional trailing group tolerates uv's source annotations for non-PyPI
|
|
2036
|
+
packages, e.g. ` + example==0.1.0 (from file:///path)`.
|
|
2037
|
+
"""
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def parse_dependency_changes(output: str) -> list[DependencyChange]:
|
|
2041
|
+
"""Parse package version changes from uv's environment-diff output.
|
|
2042
|
+
|
|
2043
|
+
uv reports environment changes as paired ` - pkg==old` / ` + pkg==new`
|
|
2044
|
+
lines; this collapses them into one `DependencyChange` per package,
|
|
2045
|
+
preserving first-seen order.
|
|
2046
|
+
|
|
2047
|
+
Args:
|
|
2048
|
+
output: Combined stdout/stderr from a `uv tool install`/`upgrade`
|
|
2049
|
+
subprocess.
|
|
2050
|
+
|
|
2051
|
+
Returns:
|
|
2052
|
+
One entry per package whose version was added, removed, or bumped.
|
|
2053
|
+
"""
|
|
2054
|
+
removed: dict[str, str] = {}
|
|
2055
|
+
added: dict[str, str] = {}
|
|
2056
|
+
order: list[str] = []
|
|
2057
|
+
seen: set[str] = set()
|
|
2058
|
+
for line in output.splitlines():
|
|
2059
|
+
match = _DEP_CHANGE_RE.match(line)
|
|
2060
|
+
if match is None:
|
|
2061
|
+
continue
|
|
2062
|
+
sign, name, version = match.group(1), match.group(2), match.group(3)
|
|
2063
|
+
(removed if sign == "-" else added)[name] = version
|
|
2064
|
+
if name not in seen:
|
|
2065
|
+
seen.add(name)
|
|
2066
|
+
order.append(name)
|
|
2067
|
+
return [
|
|
2068
|
+
DependencyChange(name=name, old=removed.get(name), new=added.get(name))
|
|
2069
|
+
for name in order
|
|
2070
|
+
]
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def format_dependency_changes(changes: Sequence[DependencyChange]) -> str:
|
|
2074
|
+
"""Render dependency changes as an aligned, human-readable block.
|
|
2075
|
+
|
|
2076
|
+
Args:
|
|
2077
|
+
changes: Parsed changes from `parse_dependency_changes`.
|
|
2078
|
+
|
|
2079
|
+
Returns:
|
|
2080
|
+
A newline-joined, column-aligned summary, or `""` when empty.
|
|
2081
|
+
"""
|
|
2082
|
+
if not changes:
|
|
2083
|
+
return ""
|
|
2084
|
+
width = max(len(change.name) for change in changes)
|
|
2085
|
+
lines: list[str] = []
|
|
2086
|
+
for change in changes:
|
|
2087
|
+
name = change.name.ljust(width)
|
|
2088
|
+
if change.kind == "bumped":
|
|
2089
|
+
lines.append(f" {name} {change.old} -> {change.new}")
|
|
2090
|
+
elif change.kind == "added":
|
|
2091
|
+
lines.append(f" {name} {change.new} (new)")
|
|
2092
|
+
else: # removed
|
|
2093
|
+
lines.append(f" {name} {change.old} (removed)")
|
|
2094
|
+
return "\n".join(lines)
|
|
2095
|
+
|
|
2096
|
+
|
|
2097
|
+
class ToolRequirementIntrospectionError(RuntimeError):
|
|
2098
|
+
"""Raised when uv tool requested requirements cannot be preserved."""
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
_EXTRA_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
|
|
2102
|
+
"""Conservative package-extra name pattern used before shell command display."""
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
_PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
|
|
2106
|
+
"""Conservative package name pattern used before shell command display."""
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def is_valid_extra_name(extra: str) -> bool:
|
|
2110
|
+
"""Return whether `extra` is safe to embed in package-extra syntax.
|
|
2111
|
+
|
|
2112
|
+
Args:
|
|
2113
|
+
extra: Candidate extra name from CLI or slash-command input.
|
|
2114
|
+
|
|
2115
|
+
Returns:
|
|
2116
|
+
`True` when the value is a conservative PEP 508-style extra name.
|
|
2117
|
+
"""
|
|
2118
|
+
return bool(_EXTRA_NAME_RE.fullmatch(extra))
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def is_valid_package_name(package: str) -> bool:
|
|
2122
|
+
"""Return whether `package` is safe to embed in a `--with` install command.
|
|
2123
|
+
|
|
2124
|
+
Args:
|
|
2125
|
+
package: Candidate package name from CLI or slash-command input.
|
|
2126
|
+
|
|
2127
|
+
Returns:
|
|
2128
|
+
`True` when the value is a conservative PEP 508-style package name.
|
|
2129
|
+
"""
|
|
2130
|
+
return bool(_PACKAGE_NAME_RE.fullmatch(package))
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
def _uv_tool_receipt_path(tool_root: Path | None = None) -> Path:
|
|
2134
|
+
"""Return the uv receipt path for the current tool environment.
|
|
2135
|
+
|
|
2136
|
+
Args:
|
|
2137
|
+
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
|
2138
|
+
|
|
2139
|
+
Returns:
|
|
2140
|
+
The expected `uv-receipt.toml` path.
|
|
2141
|
+
"""
|
|
2142
|
+
return (tool_root or Path(sys.prefix)) / "uv-receipt.toml"
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _uv_tool_receipt_data(tool_root: Path | None = None) -> dict[str, Any]:
|
|
2146
|
+
"""Return parsed uv tool receipt data for the current tool environment.
|
|
2147
|
+
|
|
2148
|
+
Args:
|
|
2149
|
+
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
|
2150
|
+
|
|
2151
|
+
Returns:
|
|
2152
|
+
Parsed TOML data from `uv-receipt.toml`.
|
|
2153
|
+
|
|
2154
|
+
Raises:
|
|
2155
|
+
ToolRequirementIntrospectionError: If the receipt cannot be read or
|
|
2156
|
+
parsed.
|
|
2157
|
+
"""
|
|
2158
|
+
receipt_path = _uv_tool_receipt_path(tool_root)
|
|
2159
|
+
try:
|
|
2160
|
+
return tomllib.loads(receipt_path.read_text(encoding="utf-8"))
|
|
2161
|
+
except FileNotFoundError as exc:
|
|
2162
|
+
msg = f"uv tool receipt not found at {receipt_path}"
|
|
2163
|
+
raise ToolRequirementIntrospectionError(msg) from exc
|
|
2164
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
2165
|
+
msg = f"Could not read uv tool receipt at {receipt_path}: {exc}"
|
|
2166
|
+
raise ToolRequirementIntrospectionError(msg) from exc
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _uv_tool_python(tool_root: Path | None = None) -> str | None:
|
|
2170
|
+
"""Return the Python interpreter recorded in the uv tool receipt.
|
|
2171
|
+
|
|
2172
|
+
Args:
|
|
2173
|
+
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
|
2174
|
+
|
|
2175
|
+
Returns:
|
|
2176
|
+
The recorded `[tool].python` value, or `None` when the receipt does not
|
|
2177
|
+
pin an interpreter.
|
|
2178
|
+
|
|
2179
|
+
Raises:
|
|
2180
|
+
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
|
|
2181
|
+
or safely re-expressed as a `--python` value.
|
|
2182
|
+
"""
|
|
2183
|
+
data = _uv_tool_receipt_data(tool_root)
|
|
2184
|
+
tool = data.get("tool")
|
|
2185
|
+
if not isinstance(tool, dict):
|
|
2186
|
+
msg = "uv tool receipt is missing `[tool]`"
|
|
2187
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2188
|
+
python = tool.get("python")
|
|
2189
|
+
if python is None:
|
|
2190
|
+
return None
|
|
2191
|
+
if not isinstance(python, str) or not python:
|
|
2192
|
+
msg = "uv tool receipt contains an invalid `[tool].python` value"
|
|
2193
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2194
|
+
return python
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def _uv_tool_with_packages(
|
|
2198
|
+
*,
|
|
2199
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2200
|
+
tool_root: Path | None = None,
|
|
2201
|
+
) -> tuple[str, ...]:
|
|
2202
|
+
"""Return package names recorded as uv tool `--with` requirements.
|
|
2203
|
+
|
|
2204
|
+
uv records the tool's requested requirements in `uv-receipt.toml`. Reading
|
|
2205
|
+
that receipt preserves only packages the user asked uv to keep, avoiding the
|
|
2206
|
+
over-broad fallback of promoting every installed transitive dependency to a
|
|
2207
|
+
top-level `--with` requirement.
|
|
2208
|
+
|
|
2209
|
+
Args:
|
|
2210
|
+
distribution_name: Main tool distribution to exclude from `--with`.
|
|
2211
|
+
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
|
|
2212
|
+
|
|
2213
|
+
Returns:
|
|
2214
|
+
A sorted tuple of validated package names to pass as `--with` values.
|
|
2215
|
+
|
|
2216
|
+
Raises:
|
|
2217
|
+
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
|
|
2218
|
+
or safely re-expressed as package-name `--with` requirements.
|
|
2219
|
+
"""
|
|
2220
|
+
data = _uv_tool_receipt_data(tool_root)
|
|
2221
|
+
tool = data.get("tool")
|
|
2222
|
+
requirements = tool.get("requirements") if isinstance(tool, dict) else None
|
|
2223
|
+
if not isinstance(requirements, list):
|
|
2224
|
+
msg = "uv tool receipt is missing `[tool].requirements`"
|
|
2225
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2226
|
+
|
|
2227
|
+
main = canonicalize_name(distribution_name)
|
|
2228
|
+
packages: set[str] = set()
|
|
2229
|
+
for entry in requirements:
|
|
2230
|
+
if not isinstance(entry, dict):
|
|
2231
|
+
msg = "uv tool receipt contains a non-table requirement entry"
|
|
2232
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2233
|
+
name = entry.get("name")
|
|
2234
|
+
if not isinstance(name, str) or not name:
|
|
2235
|
+
msg = "uv tool receipt contains a requirement without a package name"
|
|
2236
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2237
|
+
if canonicalize_name(name) == main:
|
|
2238
|
+
continue
|
|
2239
|
+
unsupported_keys = sorted(set(entry) - {"name"})
|
|
2240
|
+
if unsupported_keys:
|
|
2241
|
+
msg = (
|
|
2242
|
+
f"uv tool receipt requirement {name!r} cannot be preserved "
|
|
2243
|
+
"automatically; reinstall it manually after refreshing "
|
|
2244
|
+
"dependencies"
|
|
2245
|
+
)
|
|
2246
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2247
|
+
if not is_valid_package_name(name):
|
|
2248
|
+
msg = (
|
|
2249
|
+
f"Invalid uv tool receipt package name {name!r}: must match "
|
|
2250
|
+
f"PEP 508 ({_PACKAGE_NAME_RE.pattern})"
|
|
2251
|
+
)
|
|
2252
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2253
|
+
packages.add(name)
|
|
2254
|
+
return tuple(sorted(packages))
|
|
2255
|
+
|
|
2256
|
+
|
|
2257
|
+
def _dcode_extras_requirement(
|
|
2258
|
+
extras: Iterable[str],
|
|
2259
|
+
*,
|
|
2260
|
+
version: str | None = None,
|
|
2261
|
+
) -> str:
|
|
2262
|
+
"""Return the validated `deepagents-code[...]` requirement for a uv install.
|
|
2263
|
+
|
|
2264
|
+
Shared by the extra- and package-install commands so already-installed
|
|
2265
|
+
extras survive a `uv tool install` reinstall — a bare `deepagents-code`
|
|
2266
|
+
request would replace the tool and drop them. Returns plain
|
|
2267
|
+
`deepagents-code` when no extras or version are selected; otherwise the
|
|
2268
|
+
shell-quoted requirement form, which keeps zsh from globbing brackets.
|
|
2269
|
+
|
|
2270
|
+
Args:
|
|
2271
|
+
extras: Extra names to encode. Each is validated against PEP 508
|
|
2272
|
+
grammar before interpolation. This is the authoritative gate for
|
|
2273
|
+
caller-supplied extras (`install_extras_command`) and a
|
|
2274
|
+
redundant re-check for extras read from distribution metadata
|
|
2275
|
+
(`install_package_command`).
|
|
2276
|
+
version: Optional exact `deepagents-code` version pin.
|
|
2277
|
+
|
|
2278
|
+
Returns:
|
|
2279
|
+
Shell-safe requirement token, e.g. `deepagents-code` or
|
|
2280
|
+
`'deepagents-code[baseten,nvidia]==1.0.0'`.
|
|
2281
|
+
|
|
2282
|
+
Raises:
|
|
2283
|
+
ValueError: If any extra fails PEP 508 validation.
|
|
2284
|
+
"""
|
|
2285
|
+
names = sorted(set(extras))
|
|
2286
|
+
for name in names:
|
|
2287
|
+
if not is_valid_extra_name(name):
|
|
2288
|
+
msg = (
|
|
2289
|
+
f"Invalid extra name {name!r}: must match PEP 508 "
|
|
2290
|
+
f"({_EXTRA_NAME_RE.pattern})"
|
|
2291
|
+
)
|
|
2292
|
+
raise ValueError(msg)
|
|
2293
|
+
version_suffix = ""
|
|
2294
|
+
if version is not None:
|
|
2295
|
+
try:
|
|
2296
|
+
parsed = Version(version)
|
|
2297
|
+
except InvalidVersion as exc:
|
|
2298
|
+
msg = f"Invalid {DISTRIBUTION_NAME} version {version!r}"
|
|
2299
|
+
raise ValueError(msg) from exc
|
|
2300
|
+
version_suffix = f"=={parsed}"
|
|
2301
|
+
extras_part = f"[{','.join(names)}]" if names else ""
|
|
2302
|
+
requirement = f"{DISTRIBUTION_NAME}{extras_part}{version_suffix}"
|
|
2303
|
+
if not names and version is None:
|
|
2304
|
+
return requirement
|
|
2305
|
+
return shlex.quote(requirement)
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
def _uv_tool_install_command(
|
|
2309
|
+
*,
|
|
2310
|
+
version: str | None,
|
|
2311
|
+
include_prereleases: bool | None,
|
|
2312
|
+
distribution_name: str,
|
|
2313
|
+
extras_to_add: Iterable[str] = (),
|
|
2314
|
+
with_packages_to_add: Iterable[str] = (),
|
|
2315
|
+
reinstall: bool = False,
|
|
2316
|
+
) -> str:
|
|
2317
|
+
"""Return the receipt-preserving `uv tool install -U` command.
|
|
2318
|
+
|
|
2319
|
+
Args:
|
|
2320
|
+
version: Optional exact `deepagents-code` version pin.
|
|
2321
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
2322
|
+
`None`, follows the installed version's channel.
|
|
2323
|
+
distribution_name: Name of the installed distribution to inspect.
|
|
2324
|
+
extras_to_add: Extra names to merge with already-installed extras.
|
|
2325
|
+
with_packages_to_add: Package names to merge with the receipt's existing
|
|
2326
|
+
`--with` packages. Names already present (compared canonically) are
|
|
2327
|
+
not duplicated; genuinely new names are appended after the preserved
|
|
2328
|
+
ones. Callers must validate these names before passing them — the
|
|
2329
|
+
builder only `shlex.quote`-s them.
|
|
2330
|
+
reinstall: When `True`, add `--reinstall` so uv rebuilds the tool
|
|
2331
|
+
environment from scratch instead of patching it in place. An
|
|
2332
|
+
in-place `-U` upgrade can leave stale files behind (e.g. an old
|
|
2333
|
+
`tools.py` or its cached bytecode), which has been observed to
|
|
2334
|
+
produce a half-updated env that crashes the next server start with
|
|
2335
|
+
an `ImportError`; the preserved `--python` interpreter and `--with`
|
|
2336
|
+
packages still apply, so the rebuild keeps the existing tool
|
|
2337
|
+
context.
|
|
2338
|
+
|
|
2339
|
+
Raises:
|
|
2340
|
+
ExtrasIntrospectionError: If a metadata-sourced extra name fails PEP 508
|
|
2341
|
+
validation.
|
|
2342
|
+
ValueError: If `version` is not PEP 440 compliant.
|
|
2343
|
+
|
|
2344
|
+
Propagates `ToolRequirementIntrospectionError` if the uv tool receipt's
|
|
2345
|
+
interpreter or `--with` packages cannot be determined safely from the tool
|
|
2346
|
+
receipt.
|
|
2347
|
+
"""
|
|
2348
|
+
from deepagents_code.extras_info import (
|
|
2349
|
+
ExtrasIntrospectionError,
|
|
2350
|
+
installed_extra_names,
|
|
2351
|
+
)
|
|
2352
|
+
|
|
2353
|
+
extras = set(installed_extra_names(distribution_name, strict=True))
|
|
2354
|
+
extras.update(extras_to_add)
|
|
2355
|
+
try:
|
|
2356
|
+
requirement = _dcode_extras_requirement(extras, version=version)
|
|
2357
|
+
except ValueError as exc:
|
|
2358
|
+
if str(exc).startswith(f"Invalid {DISTRIBUTION_NAME} version"):
|
|
2359
|
+
raise
|
|
2360
|
+
msg = f"Distribution metadata yielded an invalid extra name: {exc}"
|
|
2361
|
+
raise ExtrasIntrospectionError(msg) from exc
|
|
2362
|
+
cmd = "uv tool install --reinstall -U" if reinstall else "uv tool install -U"
|
|
2363
|
+
python = _uv_tool_python()
|
|
2364
|
+
if python is not None:
|
|
2365
|
+
cmd += f" --python {shlex.quote(python)}"
|
|
2366
|
+
cmd += f" {requirement}"
|
|
2367
|
+
with_packages = list(_uv_tool_with_packages(distribution_name=distribution_name))
|
|
2368
|
+
known = {canonicalize_name(package) for package in with_packages}
|
|
2369
|
+
for package in with_packages_to_add:
|
|
2370
|
+
if canonicalize_name(package) not in known:
|
|
2371
|
+
with_packages.append(package)
|
|
2372
|
+
known.add(canonicalize_name(package))
|
|
2373
|
+
for package in with_packages:
|
|
2374
|
+
cmd += f" --with {shlex.quote(package)}"
|
|
2375
|
+
if _resolve_include_prereleases(include_prereleases):
|
|
2376
|
+
cmd += " --prerelease allow"
|
|
2377
|
+
return cmd
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def upgrade_install_command(
|
|
2381
|
+
*,
|
|
2382
|
+
include_prereleases: bool | None = None,
|
|
2383
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2384
|
+
version: str | None = None,
|
|
2385
|
+
) -> str:
|
|
2386
|
+
"""Return the uv command that upgrades dcode while clearing stale pins.
|
|
2387
|
+
|
|
2388
|
+
Built specifically to avoid the `uv tool upgrade` receipt-pin trap: when
|
|
2389
|
+
the tool was originally installed via `uv tool install deepagents-code==X.Y.Z`
|
|
2390
|
+
— or when a prior `dependency_refresh_command` rewrote the receipt with a
|
|
2391
|
+
version-pinned requirement — `uv tool upgrade deepagents-code` will only
|
|
2392
|
+
re-resolve *within* that pin and silently keep the user on the same
|
|
2393
|
+
version. Re-running `uv tool install -U deepagents-code[<extras>]` (no
|
|
2394
|
+
version pin) rewrites the receipt's requirement to unpinned so the next
|
|
2395
|
+
upgrade can actually move forward. Callers can still pass `version` when
|
|
2396
|
+
the resolver must allow pre-release dependencies for a stable app target;
|
|
2397
|
+
that prevents the root `deepagents-code` package from floating to a newer
|
|
2398
|
+
app pre-release. Installed extras and `--with` packages are preserved to
|
|
2399
|
+
mirror `dependency_refresh_command`.
|
|
2400
|
+
|
|
2401
|
+
Args:
|
|
2402
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
2403
|
+
`None`, follows the installed version's channel.
|
|
2404
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2405
|
+
already-installed extras.
|
|
2406
|
+
version: Optional exact target version. Use only when pre-release
|
|
2407
|
+
dependency resolution must not also select a root app pre-release.
|
|
2408
|
+
|
|
2409
|
+
Returns:
|
|
2410
|
+
Shell command string suitable for execution via the shell.
|
|
2411
|
+
|
|
2412
|
+
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
|
2413
|
+
determined safely from distribution metadata, or a metadata-sourced extra name
|
|
2414
|
+
fails PEP 508 validation. Also propagates `ToolRequirementIntrospectionError`
|
|
2415
|
+
if the uv tool `--with` packages or interpreter cannot be determined safely
|
|
2416
|
+
from the tool receipt. Callers choose whether to treat those errors as
|
|
2417
|
+
failures or fall back to a simpler unpinned upgrade command with a
|
|
2418
|
+
user-facing warning.
|
|
2419
|
+
"""
|
|
2420
|
+
return _uv_tool_install_command(
|
|
2421
|
+
version=version,
|
|
2422
|
+
include_prereleases=include_prereleases,
|
|
2423
|
+
distribution_name=distribution_name,
|
|
2424
|
+
)
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def dependency_refresh_command(
|
|
2428
|
+
*,
|
|
2429
|
+
version: str = __version__,
|
|
2430
|
+
include_prereleases: bool | None = None,
|
|
2431
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2432
|
+
) -> str:
|
|
2433
|
+
"""Return the uv command that refreshes deps for the current dcode version.
|
|
2434
|
+
|
|
2435
|
+
Args:
|
|
2436
|
+
version: Exact `deepagents-code` version to keep installed.
|
|
2437
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
2438
|
+
`None`, follows the installed version's channel.
|
|
2439
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2440
|
+
already-installed extras.
|
|
2441
|
+
|
|
2442
|
+
Returns:
|
|
2443
|
+
Shell command string suitable for execution via the shell.
|
|
2444
|
+
|
|
2445
|
+
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
|
2446
|
+
determined safely from distribution metadata, or a metadata-sourced extra name
|
|
2447
|
+
fails PEP 508 validation, and `ToolRequirementIntrospectionError` if the uv
|
|
2448
|
+
tool `--with` packages or interpreter cannot be determined safely from the
|
|
2449
|
+
tool receipt. `perform_dependency_refresh` converts both into a user-facing
|
|
2450
|
+
failure.
|
|
2451
|
+
"""
|
|
2452
|
+
return _uv_tool_install_command(
|
|
2453
|
+
version=version,
|
|
2454
|
+
include_prereleases=include_prereleases,
|
|
2455
|
+
distribution_name=distribution_name,
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def dependency_refresh_dry_run_command(
|
|
2460
|
+
*,
|
|
2461
|
+
version: str = __version__,
|
|
2462
|
+
include_prereleases: bool | None = None,
|
|
2463
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2464
|
+
python: str | None = None,
|
|
2465
|
+
) -> str:
|
|
2466
|
+
"""Return the uv command that plans a dependency refresh without installing.
|
|
2467
|
+
|
|
2468
|
+
Args:
|
|
2469
|
+
version: Exact `deepagents-code` version to keep installed.
|
|
2470
|
+
include_prereleases: Whether to include alpha/beta/rc releases. When
|
|
2471
|
+
`None`, follows the installed version's channel.
|
|
2472
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2473
|
+
already-installed extras and uv receipt requirements.
|
|
2474
|
+
python: Python executable for the target environment. Defaults to the
|
|
2475
|
+
running interpreter, which is the current dcode tool environment.
|
|
2476
|
+
|
|
2477
|
+
Returns:
|
|
2478
|
+
Shell command string suitable for execution via the shell.
|
|
2479
|
+
|
|
2480
|
+
Raises:
|
|
2481
|
+
ToolRequirementIntrospectionError: If the target Python or uv tool receipt
|
|
2482
|
+
requirements cannot be determined safely.
|
|
2483
|
+
"""
|
|
2484
|
+
from deepagents_code.extras_info import installed_extra_names
|
|
2485
|
+
|
|
2486
|
+
target_python = python or sys.executable
|
|
2487
|
+
if not target_python:
|
|
2488
|
+
msg = "Could not determine the running Python executable"
|
|
2489
|
+
raise ToolRequirementIntrospectionError(msg)
|
|
2490
|
+
extras = installed_extra_names(distribution_name, strict=True)
|
|
2491
|
+
requirement = _dcode_extras_requirement(extras, version=version)
|
|
2492
|
+
cmd = (
|
|
2493
|
+
"uv pip install --dry-run --python "
|
|
2494
|
+
f"{shlex.quote(target_python)} -U {requirement}"
|
|
2495
|
+
)
|
|
2496
|
+
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
|
|
2497
|
+
for package in with_packages:
|
|
2498
|
+
cmd += f" {shlex.quote(package)}"
|
|
2499
|
+
if _resolve_include_prereleases(include_prereleases):
|
|
2500
|
+
cmd += " --prerelease allow"
|
|
2501
|
+
return cmd
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def install_package_command(
|
|
2505
|
+
package: str,
|
|
2506
|
+
*,
|
|
2507
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2508
|
+
) -> str:
|
|
2509
|
+
"""Return the shell command that adds a package to the dcode tool env.
|
|
2510
|
+
|
|
2511
|
+
The result is built for *execution* (via `perform_install_package`), not for
|
|
2512
|
+
display — surfacing raw `uv tool` invocations to the user is intentionally
|
|
2513
|
+
avoided. `package` is validated here against PEP 508 grammar and then
|
|
2514
|
+
`shlex.quote`-d by the shared builder: the validation already blocks shell
|
|
2515
|
+
metacharacters, so the quoting is defense in depth that keeps the command
|
|
2516
|
+
safe even if the pattern is later loosened.
|
|
2517
|
+
|
|
2518
|
+
Delegates to `_uv_tool_install_command` (the same builder the extras path
|
|
2519
|
+
uses), passing the new package as a `--with` requirement. That builder folds
|
|
2520
|
+
already-installed extras into the pinned `deepagents-code[...]` requirement,
|
|
2521
|
+
and preserves the uv-managed Python interpreter and the receipt's existing
|
|
2522
|
+
`--with` packages. Without this, reinstalling to add a second package would
|
|
2523
|
+
replace the tool with a plain `deepagents-code` (dropping extras the user
|
|
2524
|
+
added through `/install <extra>`), rebuild with only the newest `--with`
|
|
2525
|
+
package (dropping previously configured custom providers), or silently
|
|
2526
|
+
downgrade when the latest stable app depends on prerelease packages.
|
|
2527
|
+
|
|
2528
|
+
Like the extras path (`_install_extra_uv_tool_command`), passes
|
|
2529
|
+
`reinstall=True` so the upgrade rebuilds the tool environment cleanly; see
|
|
2530
|
+
`_uv_tool_install_command`'s `reinstall` parameter for why an in-place
|
|
2531
|
+
upgrade is unsafe.
|
|
2532
|
+
|
|
2533
|
+
Args:
|
|
2534
|
+
package: Package name to install into the existing tool environment.
|
|
2535
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2536
|
+
already-installed extras and uv receipt requirements.
|
|
2537
|
+
|
|
2538
|
+
Returns:
|
|
2539
|
+
Shell command string suitable for execution via the shell.
|
|
2540
|
+
|
|
2541
|
+
Raises:
|
|
2542
|
+
ValueError: If `package` fails PEP 508 validation.
|
|
2543
|
+
|
|
2544
|
+
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
|
2545
|
+
determined safely from distribution metadata (or a metadata-sourced extra
|
|
2546
|
+
name fails PEP 508 validation), and `ToolRequirementIntrospectionError` if
|
|
2547
|
+
the uv tool receipt's interpreter or `--with` packages cannot be determined
|
|
2548
|
+
safely.
|
|
2549
|
+
"""
|
|
2550
|
+
if not _PACKAGE_NAME_RE.fullmatch(package):
|
|
2551
|
+
msg = (
|
|
2552
|
+
f"Invalid package name {package!r}: must match PEP 508 "
|
|
2553
|
+
f"({_PACKAGE_NAME_RE.pattern})"
|
|
2554
|
+
)
|
|
2555
|
+
raise ValueError(msg)
|
|
2556
|
+
return _uv_tool_install_command(
|
|
2557
|
+
version=__version__,
|
|
2558
|
+
include_prereleases=True,
|
|
2559
|
+
distribution_name=distribution_name,
|
|
2560
|
+
with_packages_to_add=(package,),
|
|
2561
|
+
reinstall=True,
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
def install_extras_command(extras: Iterable[str]) -> str:
|
|
2566
|
+
"""Return the install-script command that installs dcode extras.
|
|
2567
|
+
|
|
2568
|
+
Args:
|
|
2569
|
+
extras: Extra names to include in the tool reinstall. Validated by
|
|
2570
|
+
`_dcode_extras_requirement`, which raises `ValueError` on any name
|
|
2571
|
+
that fails PEP 508 validation.
|
|
2572
|
+
|
|
2573
|
+
Returns:
|
|
2574
|
+
Shell command string suitable for display in error messages.
|
|
2575
|
+
"""
|
|
2576
|
+
names = sorted(extras)
|
|
2577
|
+
_dcode_extras_requirement(names)
|
|
2578
|
+
if not names:
|
|
2579
|
+
return INSTALL_SCRIPT_COMMAND
|
|
2580
|
+
extras_env = shlex.quote(",".join(names))
|
|
2581
|
+
return (
|
|
2582
|
+
f"curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_EXTRAS={extras_env} bash"
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
def install_extra_command(
|
|
2587
|
+
extra: str,
|
|
2588
|
+
*,
|
|
2589
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2590
|
+
) -> str:
|
|
2591
|
+
"""Return the install-script command that adds `extra` to dcode.
|
|
2592
|
+
|
|
2593
|
+
The promoted install path is the install script (see `scripts/install.sh`).
|
|
2594
|
+
This helper is display-only and avoids uv receipt introspection so
|
|
2595
|
+
unsupported installs can surface method-specific guidance before any uv
|
|
2596
|
+
receipt is read. Already-detected extras from distribution metadata are
|
|
2597
|
+
included when available, so following the command does not drop them.
|
|
2598
|
+
|
|
2599
|
+
Args:
|
|
2600
|
+
extra: The extra name (e.g. `'quickjs'`, `'daytona'`, `'fireworks'`).
|
|
2601
|
+
Validated internally against PEP 508 grammar before interpolation
|
|
2602
|
+
into the shell command.
|
|
2603
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2604
|
+
already-installed extras.
|
|
2605
|
+
|
|
2606
|
+
Returns:
|
|
2607
|
+
Shell command string suitable for display in error messages.
|
|
2608
|
+
|
|
2609
|
+
Raises:
|
|
2610
|
+
ValueError: If `extra` fails PEP 508 validation.
|
|
2611
|
+
"""
|
|
2612
|
+
if not is_valid_extra_name(extra):
|
|
2613
|
+
msg = (
|
|
2614
|
+
f"Invalid extra name {extra!r}: must match PEP 508 "
|
|
2615
|
+
f"({_EXTRA_NAME_RE.pattern})"
|
|
2616
|
+
)
|
|
2617
|
+
raise ValueError(msg)
|
|
2618
|
+
from deepagents_code.extras_info import installed_extra_names
|
|
2619
|
+
|
|
2620
|
+
extras = installed_extra_names(distribution_name)
|
|
2621
|
+
extras.add(extra)
|
|
2622
|
+
return install_extras_command(extras)
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
def install_extra_recovery_command(extra: str) -> str:
|
|
2626
|
+
"""Return a manual recovery command for the current install method.
|
|
2627
|
+
|
|
2628
|
+
uv-managed installs can preserve the uv receipt's Python interpreter and
|
|
2629
|
+
`--with` requirements, so their recovery command uses the same uv path as
|
|
2630
|
+
the automatic installer. Unsupported methods keep the install-script command
|
|
2631
|
+
and deliberately avoid reading uv receipts.
|
|
2632
|
+
|
|
2633
|
+
Args:
|
|
2634
|
+
extra: Extra name to add.
|
|
2635
|
+
|
|
2636
|
+
Returns:
|
|
2637
|
+
Shell command string suitable for display in error messages.
|
|
2638
|
+
|
|
2639
|
+
Propagates `ValueError` if `extra` fails PEP 508 validation, and (on the uv
|
|
2640
|
+
path) `ExtrasIntrospectionError` if installed extras cannot be determined
|
|
2641
|
+
safely or `ToolRequirementIntrospectionError` if the uv receipt's
|
|
2642
|
+
interpreter or `--with` packages cannot be preserved safely.
|
|
2643
|
+
"""
|
|
2644
|
+
if detect_install_method() == "uv":
|
|
2645
|
+
return _install_extra_uv_tool_command(extra)
|
|
2646
|
+
return install_extra_command(extra)
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
def _install_extra_uv_tool_command(
|
|
2650
|
+
extra: str,
|
|
2651
|
+
*,
|
|
2652
|
+
distribution_name: str = DISTRIBUTION_NAME,
|
|
2653
|
+
) -> str:
|
|
2654
|
+
"""Return the receipt-preserving uv command that installs one dcode extra.
|
|
2655
|
+
|
|
2656
|
+
Pins the running `deepagents-code` version and allows prerelease dependency
|
|
2657
|
+
resolution so adding an extra cannot make uv backtrack to an older app release.
|
|
2658
|
+
Passes `reinstall=True` so the upgrade rebuilds the tool environment from
|
|
2659
|
+
scratch rather than patching it in place; see `_uv_tool_install_command`'s
|
|
2660
|
+
`reinstall` parameter for why an in-place upgrade is unsafe.
|
|
2661
|
+
|
|
2662
|
+
Args:
|
|
2663
|
+
extra: The extra name to add. Validated against PEP 508 grammar before
|
|
2664
|
+
interpolation into the shell command.
|
|
2665
|
+
distribution_name: Name of the installed distribution to inspect for
|
|
2666
|
+
already-installed extras and uv receipt requirements.
|
|
2667
|
+
|
|
2668
|
+
Raises:
|
|
2669
|
+
ValueError: If `extra` fails PEP 508 validation.
|
|
2670
|
+
|
|
2671
|
+
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
|
2672
|
+
determined safely from distribution metadata, and
|
|
2673
|
+
`ToolRequirementIntrospectionError` if the uv tool receipt's interpreter or
|
|
2674
|
+
`--with` packages cannot be preserved safely.
|
|
2675
|
+
"""
|
|
2676
|
+
if not is_valid_extra_name(extra):
|
|
2677
|
+
msg = (
|
|
2678
|
+
f"Invalid extra name {extra!r}: must match PEP 508 "
|
|
2679
|
+
f"({_EXTRA_NAME_RE.pattern})"
|
|
2680
|
+
)
|
|
2681
|
+
raise ValueError(msg)
|
|
2682
|
+
return _uv_tool_install_command(
|
|
2683
|
+
version=__version__,
|
|
2684
|
+
include_prereleases=True,
|
|
2685
|
+
distribution_name=distribution_name,
|
|
2686
|
+
extras_to_add=(extra,),
|
|
2687
|
+
reinstall=True,
|
|
2688
|
+
)
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def editable_extra_hint(extra: str) -> str:
|
|
2692
|
+
"""Return the canonical action hint for editable installs missing an extra.
|
|
2693
|
+
|
|
2694
|
+
Shared by every site that detects an editable install and points the user
|
|
2695
|
+
at the correct `uv tool install --editable` invocation, so wording stays
|
|
2696
|
+
consistent and the literal `[<extra>]` bracket fragment is centrally
|
|
2697
|
+
defined (callers that print through Rich markup must still escape it).
|
|
2698
|
+
"""
|
|
2699
|
+
return (
|
|
2700
|
+
"Rerun your `uv tool install --editable` command with "
|
|
2701
|
+
f"`--with '{DISTRIBUTION_NAME}[{extra}]'` added so the extra is "
|
|
2702
|
+
"resolved against the editable source."
|
|
2703
|
+
)
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
def editable_package_hint(package: str) -> str:
|
|
2707
|
+
"""Return the canonical action hint for editable installs needing a package.
|
|
2708
|
+
|
|
2709
|
+
Editable installs can't have packages added automatically, so this points
|
|
2710
|
+
the user at adding it to their own development environment. Phrased without
|
|
2711
|
+
a raw install command, since surfacing `uv tool` invocations to the user is
|
|
2712
|
+
intentionally avoided.
|
|
2713
|
+
"""
|
|
2714
|
+
return (
|
|
2715
|
+
f"Add '{package}' to your editable checkout's environment (the one your "
|
|
2716
|
+
"editable install of dcode runs from), then relaunch."
|
|
2717
|
+
)
|
|
2718
|
+
|
|
2719
|
+
|
|
2720
|
+
async def perform_install_extra(
|
|
2721
|
+
extra: str,
|
|
2722
|
+
*,
|
|
2723
|
+
progress: UpgradeProgressCallback | None = None,
|
|
2724
|
+
log_path: Path | None = None,
|
|
2725
|
+
) -> tuple[bool, str]:
|
|
2726
|
+
"""Add `extra` to the installed dcode tool environment.
|
|
2727
|
+
|
|
2728
|
+
Runs `uv tool install --reinstall -U 'deepagents-code[<extras>]==<current>'
|
|
2729
|
+
--prerelease allow`, preserving any extras that are already installed.
|
|
2730
|
+
Editable installs are refused — the caller should rerun their
|
|
2731
|
+
`uv tool install --editable` command with `--with 'deepagents-code[<extra>]'`
|
|
2732
|
+
added so the extra is resolved against the editable source.
|
|
2733
|
+
|
|
2734
|
+
Args:
|
|
2735
|
+
extra: The extra name to install. Must satisfy `is_valid_extra_name`;
|
|
2736
|
+
invalid names are rejected without invoking uv (defense in depth
|
|
2737
|
+
against shell injection via the `--force`/`--yes` bypass paths).
|
|
2738
|
+
progress: Optional callback invoked for each output line.
|
|
2739
|
+
log_path: Optional path to persist command output.
|
|
2740
|
+
|
|
2741
|
+
Returns:
|
|
2742
|
+
`(success, output)` — *output* is the combined stdout/stderr, or an
|
|
2743
|
+
explanatory error message when the install method is unsupported
|
|
2744
|
+
or `extra` is malformed.
|
|
2745
|
+
"""
|
|
2746
|
+
if not is_valid_extra_name(extra):
|
|
2747
|
+
return False, (
|
|
2748
|
+
f"Invalid extra name {extra!r}: must match {_EXTRA_NAME_RE.pattern}"
|
|
2749
|
+
)
|
|
2750
|
+
method = detect_install_method()
|
|
2751
|
+
if method == "unknown":
|
|
2752
|
+
return False, (
|
|
2753
|
+
"Editable install detected — cannot add extras automatically.\n"
|
|
2754
|
+
+ editable_extra_hint(extra)
|
|
2755
|
+
)
|
|
2756
|
+
if method == "brew":
|
|
2757
|
+
# Homebrew formula doesn't expose extras; uv tool install is the
|
|
2758
|
+
# right escape hatch but would conflict with the brew-managed binary.
|
|
2759
|
+
return False, (
|
|
2760
|
+
"Homebrew install detected — extras are not supported via brew. "
|
|
2761
|
+
f"Reinstall with `{install_extra_command(extra)}` to switch to a "
|
|
2762
|
+
"uv-managed tool install with extras."
|
|
2763
|
+
)
|
|
2764
|
+
if method == "other":
|
|
2765
|
+
return False, (
|
|
2766
|
+
"Unsupported install method detected — cannot add extras without "
|
|
2767
|
+
"knowing which environment provides `dcode`. Reinstall with "
|
|
2768
|
+
f"`{install_extra_command(extra)}` to switch to a uv-managed tool "
|
|
2769
|
+
"install with extras."
|
|
2770
|
+
)
|
|
2771
|
+
|
|
2772
|
+
if not shutil.which("uv"):
|
|
2773
|
+
return False, (
|
|
2774
|
+
"`uv` not found on PATH. Reinstall dcode following the docs, or "
|
|
2775
|
+
"install uv (https://docs.astral.sh/uv/) so extras can be added."
|
|
2776
|
+
)
|
|
2777
|
+
|
|
2778
|
+
from deepagents_code.extras_info import ExtrasIntrospectionError
|
|
2779
|
+
|
|
2780
|
+
try:
|
|
2781
|
+
cmd = _install_extra_uv_tool_command(extra)
|
|
2782
|
+
except (
|
|
2783
|
+
ExtrasIntrospectionError,
|
|
2784
|
+
ToolRequirementIntrospectionError,
|
|
2785
|
+
ValueError,
|
|
2786
|
+
) as exc:
|
|
2787
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
2788
|
+
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
2789
|
+
|
|
2790
|
+
|
|
2791
|
+
async def perform_install_package(
|
|
2792
|
+
package: str,
|
|
2793
|
+
*,
|
|
2794
|
+
progress: UpgradeProgressCallback | None = None,
|
|
2795
|
+
log_path: Path | None = None,
|
|
2796
|
+
) -> tuple[bool, str]:
|
|
2797
|
+
"""Add an arbitrary `package` to the installed dcode tool environment.
|
|
2798
|
+
|
|
2799
|
+
Runs `uv tool install --reinstall -U 'deepagents-code[<extras>]==<current>'
|
|
2800
|
+
--with <package> --prerelease allow`, the escape hatch for a provider whose
|
|
2801
|
+
package is not a `deepagents-code` extra (e.g. a custom or in-house
|
|
2802
|
+
`class_path` model). Already-installed extras are preserved so the reinstall
|
|
2803
|
+
does not drop them.
|
|
2804
|
+
Editable installs are refused
|
|
2805
|
+
— the caller should rerun their `uv tool install --editable` command with
|
|
2806
|
+
`--with <package>` added so it resolves against the editable source.
|
|
2807
|
+
|
|
2808
|
+
Args:
|
|
2809
|
+
package: The package name to install. Must satisfy
|
|
2810
|
+
`is_valid_package_name`; invalid names are rejected without invoking
|
|
2811
|
+
uv (defense in depth against shell injection via the
|
|
2812
|
+
`--force`/`--yes` bypass paths).
|
|
2813
|
+
progress: Optional callback invoked for each output line.
|
|
2814
|
+
log_path: Optional path to persist command output.
|
|
2815
|
+
|
|
2816
|
+
Returns:
|
|
2817
|
+
`(success, output)` — on success, *output* is the combined
|
|
2818
|
+
stdout/stderr from the install. On failure it is an explanatory
|
|
2819
|
+
message: when the install method is unsupported, `package` is
|
|
2820
|
+
malformed, `uv` is unavailable, or the install subprocess fails or
|
|
2821
|
+
times out.
|
|
2822
|
+
"""
|
|
2823
|
+
if not is_valid_package_name(package):
|
|
2824
|
+
return False, (
|
|
2825
|
+
f"Invalid package name {package!r}: must match {_PACKAGE_NAME_RE.pattern}"
|
|
2826
|
+
)
|
|
2827
|
+
method = detect_install_method()
|
|
2828
|
+
if method == "unknown":
|
|
2829
|
+
return False, (
|
|
2830
|
+
"Editable install detected — cannot add packages automatically.\n"
|
|
2831
|
+
+ editable_package_hint(package)
|
|
2832
|
+
)
|
|
2833
|
+
if method == "brew":
|
|
2834
|
+
return False, (
|
|
2835
|
+
"Homebrew install detected — packages can't be added to a brew "
|
|
2836
|
+
"install. Reinstall dcode as a uv-managed tool (see the "
|
|
2837
|
+
"installation docs) to enable adding packages."
|
|
2838
|
+
)
|
|
2839
|
+
if method == "other":
|
|
2840
|
+
return False, (
|
|
2841
|
+
"Unsupported install method detected — cannot add packages without "
|
|
2842
|
+
"knowing which environment provides `dcode`. Reinstall dcode "
|
|
2843
|
+
"as a uv-managed tool (see the installation docs) to enable "
|
|
2844
|
+
"adding packages."
|
|
2845
|
+
)
|
|
2846
|
+
|
|
2847
|
+
if not shutil.which("uv"):
|
|
2848
|
+
return False, (
|
|
2849
|
+
"Package installs require uv, which was not found. Reinstall "
|
|
2850
|
+
"dcode following the installation docs so packages can be "
|
|
2851
|
+
"added."
|
|
2852
|
+
)
|
|
2853
|
+
|
|
2854
|
+
from deepagents_code.extras_info import ExtrasIntrospectionError
|
|
2855
|
+
|
|
2856
|
+
try:
|
|
2857
|
+
cmd = install_package_command(package)
|
|
2858
|
+
except ValueError as exc:
|
|
2859
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
2860
|
+
except (ExtrasIntrospectionError, ToolRequirementIntrospectionError) as exc:
|
|
2861
|
+
# Distinct from a malformed package name: the running distribution's own
|
|
2862
|
+
# metadata, or the uv tool receipt, could not be read or parsed. Leave a
|
|
2863
|
+
# breadcrumb so the cause is recoverable from logs, even though the user
|
|
2864
|
+
# message is unchanged.
|
|
2865
|
+
logger.warning(
|
|
2866
|
+
"Could not introspect installed extras or uv receipt for package "
|
|
2867
|
+
"install of %r",
|
|
2868
|
+
package,
|
|
2869
|
+
exc_info=True,
|
|
2870
|
+
)
|
|
2871
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
2872
|
+
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
# ---------------------------------------------------------------------------
|
|
2876
|
+
# Config helpers
|
|
2877
|
+
# ---------------------------------------------------------------------------
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def is_update_check_enabled() -> bool:
|
|
2881
|
+
"""Return whether update checks are enabled.
|
|
2882
|
+
|
|
2883
|
+
Checks `DEEPAGENTS_CODE_NO_UPDATE_CHECK` env var and the `[update].check` key
|
|
2884
|
+
in `config.toml`.
|
|
2885
|
+
|
|
2886
|
+
Defaults to enabled.
|
|
2887
|
+
"""
|
|
2888
|
+
from deepagents_code._env_vars import NO_UPDATE_CHECK
|
|
2889
|
+
|
|
2890
|
+
if os.environ.get(NO_UPDATE_CHECK):
|
|
2891
|
+
return False
|
|
2892
|
+
return _read_update_config().get("check", True)
|
|
2893
|
+
|
|
2894
|
+
|
|
2895
|
+
def is_auto_update_enabled() -> bool:
|
|
2896
|
+
"""Return whether auto-update is enabled.
|
|
2897
|
+
|
|
2898
|
+
Opt-out via `DEEPAGENTS_CODE_AUTO_UPDATE=0` env var or
|
|
2899
|
+
`[update].auto_update = false` in `config.toml`.
|
|
2900
|
+
|
|
2901
|
+
Defaults to `True`.
|
|
2902
|
+
|
|
2903
|
+
Unrecognized env values (neither truthy nor falsy) are ignored with a
|
|
2904
|
+
warning and fall through to the config read below.
|
|
2905
|
+
|
|
2906
|
+
If `config.toml` exists but cannot be parsed, returns `False` (fail-closed):
|
|
2907
|
+
a corrupt file may hold an explicit opt-out, so it is not treated as the
|
|
2908
|
+
permissive default. A genuinely absent config falls through to `True`.
|
|
2909
|
+
|
|
2910
|
+
Always disabled for editable installs.
|
|
2911
|
+
"""
|
|
2912
|
+
from deepagents_code._env_vars import AUTO_UPDATE, classify_env_bool
|
|
2913
|
+
from deepagents_code.config import _is_editable_install
|
|
2914
|
+
|
|
2915
|
+
if _is_editable_install():
|
|
2916
|
+
return False
|
|
2917
|
+
if AUTO_UPDATE in os.environ:
|
|
2918
|
+
raw = os.environ[AUTO_UPDATE]
|
|
2919
|
+
classified = classify_env_bool(raw)
|
|
2920
|
+
if classified is not None:
|
|
2921
|
+
return classified
|
|
2922
|
+
# Unrecognized boolean token: warn and fall through to the config read
|
|
2923
|
+
# below (which itself fails closed on a corrupt config), mirroring
|
|
2924
|
+
# `config_manifest._coerce_env`. With the opt-out default an absent or
|
|
2925
|
+
# default config leaves auto-update on, so an ignored disable attempt
|
|
2926
|
+
# (e.g. a typo like `ture`) must be surfaced rather than swallowed.
|
|
2927
|
+
logger.warning("Ignoring %s=%r (expected bool)", AUTO_UPDATE, raw)
|
|
2928
|
+
try:
|
|
2929
|
+
config = _read_update_config_strict()
|
|
2930
|
+
except _ConfigReadError:
|
|
2931
|
+
# The config exists but cannot be parsed. Fail *closed* here even though
|
|
2932
|
+
# the default is opt-out: a corrupt file may hold an explicit
|
|
2933
|
+
# `auto_update = false`, and silently re-enabling auto-update (which
|
|
2934
|
+
# upgrades and re-execs the process) against an unreadable opt-out is
|
|
2935
|
+
# worse than skipping the upgrade. A genuinely absent config still
|
|
2936
|
+
# falls through to the opt-out default below.
|
|
2937
|
+
logger.warning(
|
|
2938
|
+
"Could not read [update] config; disabling auto-update until it is "
|
|
2939
|
+
"readable",
|
|
2940
|
+
exc_info=True,
|
|
2941
|
+
)
|
|
2942
|
+
return False
|
|
2943
|
+
return config.get("auto_update", True)
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def set_auto_update(enabled: bool) -> None:
|
|
2947
|
+
"""Persist the auto-update preference to `config.toml`.
|
|
2948
|
+
|
|
2949
|
+
Writes `[update].auto_update` so the setting survives across sessions.
|
|
2950
|
+
|
|
2951
|
+
Args:
|
|
2952
|
+
enabled: Whether auto-update should be enabled.
|
|
2953
|
+
"""
|
|
2954
|
+
import contextlib
|
|
2955
|
+
import tempfile
|
|
2956
|
+
from pathlib import Path
|
|
2957
|
+
|
|
2958
|
+
import tomli_w
|
|
2959
|
+
|
|
2960
|
+
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
2961
|
+
if DEFAULT_CONFIG_PATH.exists():
|
|
2962
|
+
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
2963
|
+
data = tomllib.load(f)
|
|
2964
|
+
else:
|
|
2965
|
+
data = {}
|
|
2966
|
+
|
|
2967
|
+
if "update" not in data:
|
|
2968
|
+
data["update"] = {}
|
|
2969
|
+
data["update"]["auto_update"] = enabled
|
|
2970
|
+
|
|
2971
|
+
fd, tmp_path = tempfile.mkstemp(dir=DEFAULT_CONFIG_PATH.parent, suffix=".tmp")
|
|
2972
|
+
try:
|
|
2973
|
+
with os.fdopen(fd, "wb") as f:
|
|
2974
|
+
tomli_w.dump(data, f)
|
|
2975
|
+
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
|
2976
|
+
except BaseException:
|
|
2977
|
+
with contextlib.suppress(OSError):
|
|
2978
|
+
Path(tmp_path).unlink()
|
|
2979
|
+
raise
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
class _ConfigReadError(Exception):
|
|
2983
|
+
"""Internal: `config.toml` exists but could not be read or parsed.
|
|
2984
|
+
|
|
2985
|
+
Lets callers that care about the difference (e.g. `is_auto_update_enabled`,
|
|
2986
|
+
which fails closed) distinguish a corrupt config from a genuinely absent
|
|
2987
|
+
one. A missing file is *not* an error and returns an empty config.
|
|
2988
|
+
"""
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _read_update_config_strict() -> dict[str, bool]:
|
|
2992
|
+
"""Read `[update]` section from `config.toml`, surfacing read errors.
|
|
2993
|
+
|
|
2994
|
+
Returns:
|
|
2995
|
+
A dict of boolean config values; empty when the file is absent.
|
|
2996
|
+
|
|
2997
|
+
Raises:
|
|
2998
|
+
_ConfigReadError: When the file exists but cannot be opened or parsed.
|
|
2999
|
+
"""
|
|
3000
|
+
if not DEFAULT_CONFIG_PATH.exists():
|
|
3001
|
+
return {}
|
|
3002
|
+
try:
|
|
3003
|
+
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
3004
|
+
data = tomllib.load(f)
|
|
3005
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
3006
|
+
raise _ConfigReadError from exc
|
|
3007
|
+
section = data.get("update", {})
|
|
3008
|
+
return {k: v for k, v in section.items() if isinstance(v, bool)}
|
|
3009
|
+
|
|
3010
|
+
|
|
3011
|
+
def _read_update_config() -> dict[str, bool]:
|
|
3012
|
+
"""Read `[update]` section from `config.toml`.
|
|
3013
|
+
|
|
3014
|
+
Returns:
|
|
3015
|
+
A dict of boolean config values, empty on missing/unreadable file.
|
|
3016
|
+
"""
|
|
3017
|
+
try:
|
|
3018
|
+
return _read_update_config_strict()
|
|
3019
|
+
except _ConfigReadError:
|
|
3020
|
+
logger.warning("Could not read [update] config — using defaults", exc_info=True)
|
|
3021
|
+
return {}
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
def is_auto_update_explicitly_set() -> bool:
|
|
3025
|
+
"""Return whether the user explicitly chose an auto-update preference.
|
|
3026
|
+
|
|
3027
|
+
`True` when `DEEPAGENTS_CODE_AUTO_UPDATE` holds a recognized boolean or
|
|
3028
|
+
`[update].auto_update` is present in `config.toml`. Distinguishes a
|
|
3029
|
+
deliberate opt-in/out from the implicit opt-out default.
|
|
3030
|
+
"""
|
|
3031
|
+
from deepagents_code._env_vars import AUTO_UPDATE, classify_env_bool
|
|
3032
|
+
|
|
3033
|
+
if (
|
|
3034
|
+
AUTO_UPDATE in os.environ
|
|
3035
|
+
and classify_env_bool(os.environ[AUTO_UPDATE]) is not None
|
|
3036
|
+
):
|
|
3037
|
+
return True
|
|
3038
|
+
return "auto_update" in _read_update_config()
|
|
3039
|
+
|
|
3040
|
+
|
|
3041
|
+
def should_announce_auto_update_default() -> bool:
|
|
3042
|
+
"""Return whether to show the one-time auto-update default migration notice.
|
|
3043
|
+
|
|
3044
|
+
`True` when no explicit env/config preference is set (so auto-update is on
|
|
3045
|
+
only *implicitly*, via the opt-out default) and the notice has not been
|
|
3046
|
+
acknowledged yet. This does not itself verify that auto-update is enabled;
|
|
3047
|
+
callers must gate on `is_auto_update_enabled` first (e.g. an editable
|
|
3048
|
+
install has no explicit preference but never auto-updates).
|
|
3049
|
+
"""
|
|
3050
|
+
if is_auto_update_explicitly_set():
|
|
3051
|
+
return False
|
|
3052
|
+
return not _read_update_state().get("auto_update_default_acknowledged", False)
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
def mark_auto_update_default_acknowledged() -> bool:
|
|
3056
|
+
"""Record that the one-time auto-update default migration notice was shown.
|
|
3057
|
+
|
|
3058
|
+
Returns:
|
|
3059
|
+
`True` if the acknowledgement was persisted. `False` means the state
|
|
3060
|
+
write failed, so the notice will fire again on the next launch;
|
|
3061
|
+
callers should surface that rather than letting the repeat
|
|
3062
|
+
look like a bug.
|
|
3063
|
+
"""
|
|
3064
|
+
return _write_update_state({"auto_update_default_acknowledged": True})
|
|
3065
|
+
|
|
3066
|
+
|
|
3067
|
+
def _note_install_baseline() -> None:
|
|
3068
|
+
"""Pre-acknowledge the auto-update default notice for a fresh install.
|
|
3069
|
+
|
|
3070
|
+
The migration notice (`should_announce_auto_update_default`) is intended to
|
|
3071
|
+
warn users who ran dcode *before* auto-update became the opt-out default; a
|
|
3072
|
+
brand-new install never experienced the old behavior, so the notice is
|
|
3073
|
+
meaningless to it. Call this on the first launch ever (see
|
|
3074
|
+
`should_show_whats_new`) so the notice never leaks into a new install — the
|
|
3075
|
+
notice itself fires pre-TUI in `_run_startup_auto_update`.
|
|
3076
|
+
|
|
3077
|
+
Writes nothing when the user already set an explicit preference.
|
|
3078
|
+
"""
|
|
3079
|
+
if is_auto_update_explicitly_set():
|
|
3080
|
+
return
|
|
3081
|
+
if not mark_auto_update_default_acknowledged():
|
|
3082
|
+
# Fail-soft: the same unwritable state dir also drops the adjacent
|
|
3083
|
+
# `seen_version` write, so the install stays "first run ever" and the
|
|
3084
|
+
# stamp is retried next launch. Log the operation for context — the
|
|
3085
|
+
# generic write warning in `_write_update_state` can't say which write
|
|
3086
|
+
# failed when both fire back-to-back.
|
|
3087
|
+
logger.debug(
|
|
3088
|
+
"Could not stamp install baseline; the auto-update default notice "
|
|
3089
|
+
"will be re-evaluated on the next launch",
|
|
3090
|
+
)
|
|
3091
|
+
|
|
3092
|
+
|
|
3093
|
+
# ---------------------------------------------------------------------------
|
|
3094
|
+
# "What's new" tracking
|
|
3095
|
+
# ---------------------------------------------------------------------------
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
def get_seen_version() -> str | None:
|
|
3099
|
+
"""Return the last version the user saw the "what's new" banner for."""
|
|
3100
|
+
value = _read_update_state().get("seen_version")
|
|
3101
|
+
return value if isinstance(value, str) else None
|
|
3102
|
+
|
|
3103
|
+
|
|
3104
|
+
def mark_version_seen(version: str) -> None:
|
|
3105
|
+
"""Record that the user has seen the "what's new" banner for *version*."""
|
|
3106
|
+
_write_update_state({"seen_version": version, "seen_at": time.time()})
|
|
3107
|
+
|
|
3108
|
+
|
|
3109
|
+
def should_show_whats_new() -> bool:
|
|
3110
|
+
"""Return `True` if this is the first launch on a newer version."""
|
|
3111
|
+
seen = get_seen_version()
|
|
3112
|
+
if seen is None:
|
|
3113
|
+
# First run ever — mark current as seen, don't show banner. This is the
|
|
3114
|
+
# canonical fresh-install signal, so also pre-acknowledge the
|
|
3115
|
+
# auto-update default migration notice (which only applies to users who
|
|
3116
|
+
# predate the opt-out default) before it can fire on a later launch.
|
|
3117
|
+
_note_install_baseline()
|
|
3118
|
+
mark_version_seen(__version__)
|
|
3119
|
+
return False
|
|
3120
|
+
try:
|
|
3121
|
+
return _parse_version(__version__) > _parse_version(seen)
|
|
3122
|
+
except InvalidVersion:
|
|
3123
|
+
logger.debug("Failed to compare versions for what's-new check", exc_info=True)
|
|
3124
|
+
return False
|