diff --git a/docs/upgrade.md b/docs/upgrade.md index 943d9f41a1..46c83ea0f5 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -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 ` 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. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index c8fcad7c7e..3e1e533284 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -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": [ @@ -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 + 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)) 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 diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index c769665920..897027934e 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -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."""