Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ specify self upgrade --tag vX.Y.Z[suffix]

Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.

Tool detection recognizes current and legacy default installation directories, as well as directories configured with `UV_TOOL_DIR`, `PIPX_HOME`, or `XDG_DATA_HOME`. Keep the same directory environment variables set when upgrading a relocated installation so the installer also targets that location.

Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.

Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases.
Expand Down
24 changes: 21 additions & 3 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,22 @@ def _render_argv(argv: list[str]) -> str:

_INSTALLER_PATH_PREFIXES: dict[str, list[str]] = {
"uv-tool": [
"${UV_TOOL_DIR}/specify-cli/",
"${XDG_DATA_HOME}/uv/tools/specify-cli/",
"~/.local/share/uv/tools/specify-cli/",
"${APPDATA}/uv/tools/specify-cli/",
"${APPDATA}/uv/data/tools/specify-cli/",
"~/Library/Application Support/uv/tools/specify-cli/",
"%LOCALAPPDATA%\\uv\\tools\\specify-cli\\",
],
"pipx": [
"${PIPX_HOME}/venvs/specify-cli/",
"${XDG_DATA_HOME}/pipx/venvs/specify-cli/",
"~/.local/share/pipx/venvs/specify-cli/",
"~/Library/Application Support/pipx/venvs/specify-cli/",
"${LOCALAPPDATA}/pipx/pipx/venvs/specify-cli/",
"~/.local/pipx/venvs/specify-cli/",
"~/pipx/venvs/specify-cli/",
"%LOCALAPPDATA%\\pipx\\venvs\\specify-cli\\",
],
"uvx-ephemeral": [
Expand Down Expand Up @@ -343,18 +354,25 @@ def _validate_tag(tag: str) -> str:
def _expand_prefix(prefix: str) -> Path | None:
"""Expand `~` or `%LOCALAPPDATA%`-style tokens in a path prefix."""

expanded = os.path.expanduser(prefix)
# An unset or empty directory override must not become a root-level prefix.
for token in _UNRESOLVED_ENV_VAR_RE.findall(prefix):
if not os.environ.get(token.strip("${}%")):
return None
Comment thread
Copilot marked this conversation as resolved.
expanded = prefix
if "%LOCALAPPDATA%" in expanded:
local_app_data = os.environ.get("LOCALAPPDATA")
if not local_app_data:
return None
expanded = expanded.replace("%LOCALAPPDATA%", local_app_data)
expanded = os.path.expandvars(expanded)
expanded = os.path.expanduser(os.path.expandvars(expanded))
Comment thread
Copilot marked this conversation as resolved.
if _UNRESOLVED_ENV_VAR_RE.search(expanded):
return None
try:
expanded_path = Path(expanded)
return expanded_path.resolve() if expanded_path.is_absolute() else expanded_path
# uv and pipx resolve their relative tool directories against the working directory.
if expanded_path.is_absolute() or prefix.startswith(("${UV_TOOL_DIR}/", "${PIPX_HOME}/")):
return expanded_path.resolve()
return expanded_path
except OSError:
return None

Expand Down
124 changes: 124 additions & 0 deletions tests/specify_cli/test_version_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,130 @@
)


class TestRelocatedToolDetection:
@pytest.mark.parametrize("value", ["./tools", "../tools"])
@pytest.mark.parametrize("environment,expected", [
("specify-cli", _InstallMethod.UV_TOOL),
("specify-cli2", _InstallMethod.UNSUPPORTED),
])
def test_relative_uv_tool_dir(self, monkeypatch, tmp_path, value, environment, expected):
working_dir = tmp_path / "working"
working_dir.mkdir()
monkeypatch.chdir(working_dir)
monkeypatch.setenv("UV_TOOL_DIR", value)
entrypoint = (working_dir / value / environment / "bin" / "specify").resolve()
entrypoint.parent.mkdir(parents=True)
entrypoint.write_text("#!/usr/bin/env python\n")
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.subprocess.run"
) as registry_lookup:
assert _detect_install_method(str(entrypoint)) == expected
registry_lookup.assert_not_called()

@pytest.mark.parametrize("suffix", ["uv/tools/specify-cli/", "pipx/venvs/specify-cli/"])
def test_relative_xdg_data_home_is_not_accepted(self, monkeypatch, tmp_path, suffix):
from specify_cli._version import _expand_prefix, _path_is_within_prefix

monkeypatch.chdir(tmp_path)
monkeypatch.setenv("XDG_DATA_HOME", "./data")
prefix = _expand_prefix("${XDG_DATA_HOME}/" + suffix)
assert not _path_is_within_prefix((tmp_path / "data" / suffix / "bin/specify"), prefix)

@pytest.mark.parametrize("environment,expected", [
("specify-cli", _InstallMethod.PIPX),
("specify-cli2", _InstallMethod.UNSUPPORTED),
])
def test_pipx_home_with_tilde(self, monkeypatch, tmp_path, environment, expected):
home = tmp_path / "home"
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("PIPX_HOME", "~/pipx-home")
entrypoint = home / "pipx-home" / "venvs" / environment / "bin" / "specify"
entrypoint.parent.mkdir(parents=True)
entrypoint.write_text("#!/usr/bin/env python\n")
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.subprocess.run"
) as registry_lookup:
assert _detect_install_method(str(entrypoint)) == expected
registry_lookup.assert_not_called()

@pytest.mark.parametrize("value", ["./pipx-home", "../pipx-home"])
@pytest.mark.parametrize("environment,expected", [
("specify-cli", _InstallMethod.PIPX),
("specify-cli2", _InstallMethod.UNSUPPORTED),
])
def test_relative_pipx_home(self, monkeypatch, tmp_path, value, environment, expected):
working_dir = tmp_path / "working"
working_dir.mkdir()
monkeypatch.chdir(working_dir)
monkeypatch.setenv("PIPX_HOME", value)
entrypoint = (working_dir / value / "venvs" / environment / "bin" / "specify").resolve()
entrypoint.parent.mkdir(parents=True)
entrypoint.write_text("#!/usr/bin/env python\n")
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.subprocess.run"
) as registry_lookup:
assert _detect_install_method(str(entrypoint)) == expected
registry_lookup.assert_not_called()

@pytest.mark.parametrize("env_name,suffix,method", [
("UV_TOOL_DIR", "specify-cli/bin/specify", _InstallMethod.UV_TOOL),
("XDG_DATA_HOME", "uv/tools/specify-cli/bin/specify", _InstallMethod.UV_TOOL),
("APPDATA", "uv/data/tools/specify-cli/bin/specify", _InstallMethod.UV_TOOL),
("APPDATA", "uv/tools/specify-cli/bin/specify", _InstallMethod.UV_TOOL),
("HOME", "Library/Application Support/uv/tools/specify-cli/bin/specify", _InstallMethod.UV_TOOL),
("PIPX_HOME", "venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
("XDG_DATA_HOME", "pipx/venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
("LOCALAPPDATA", "pipx/pipx/venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
("HOME", ".local/share/pipx/venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
("HOME", "Library/Application Support/pipx/venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
("HOME", "pipx/venvs/specify-cli/bin/specify", _InstallMethod.PIPX),
])
def test_tool_directory_matches(self, monkeypatch, tmp_path, env_name, suffix, method):
root = tmp_path / "tool data"
monkeypatch.setenv(env_name, str(root))
if env_name == "HOME":
monkeypatch.setenv("USERPROFILE", str(root))
entrypoint = root / suffix
entrypoint.parent.mkdir(parents=True)
entrypoint.write_text("#!/usr/bin/env python\n")
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.subprocess.run"
) as registry_lookup:
actual, signals = _detect_install_method(str(entrypoint), include_signals=True)
assert actual == method
assert signals.matched_tier == 1
registry_lookup.assert_not_called()

@pytest.mark.parametrize("env_name,suffix", [
("UV_TOOL_DIR", "specify-cli2/bin/specify"),
("APPDATA", "uv/tools/specify-cli2/bin/specify"),
("HOME", "Library/Application Support/uv/tools/specify-cli2/bin/specify"),
("XDG_DATA_HOME", "uv/tools/specify-cli2/bin/specify"),
("PIPX_HOME", "venvs/specify-cli2/bin/specify"),
])
def test_sibling_environment_is_not_accepted(self, monkeypatch, tmp_path, env_name, suffix):
monkeypatch.setenv(env_name, str(tmp_path))
if env_name == "HOME":
monkeypatch.setenv("USERPROFILE", str(tmp_path))
entrypoint = tmp_path / suffix
entrypoint.parent.mkdir(parents=True)
entrypoint.write_text("#!/usr/bin/env python\n")
with patch("specify_cli._version._editable_marker_seen", return_value=False):
assert _detect_install_method(str(entrypoint)) == _InstallMethod.UNSUPPORTED

@pytest.mark.parametrize("value", [None, ""])
@pytest.mark.parametrize("variable", ["UV_TOOL_DIR", "PIPX_HOME", "XDG_DATA_HOME"])
def test_unconfigured_prefix_is_ignored(self, monkeypatch, variable, value):
from specify_cli._version import _expand_prefix

if value is None:
monkeypatch.delenv(variable, raising=False)
else:
monkeypatch.setenv(variable, value)
assert _expand_prefix("${" + variable + "}/specify-cli/") is None


class TestDetectionUvTool:
"""Tier-1 path-prefix detection for uv-tool installs."""

Expand Down
Loading