diff --git a/sqlit/domains/connections/app/install_strategy.py b/sqlit/domains/connections/app/install_strategy.py index a438977d..0eaf9ea8 100644 --- a/sqlit/domains/connections/app/install_strategy.py +++ b/sqlit/domains/connections/app/install_strategy.py @@ -103,18 +103,23 @@ class InstallOption: def detect_install_method(*, probe: SystemProbeProtocol | None = None) -> str: """Detect how sqlit was installed/is running. - Returns one of: 'pipx', 'uvx', 'uv', 'conda', 'pip', or 'unknown'. - 'pipx', 'uvx', 'uv' (uv run), and 'conda' are high-confidence detections. + Returns one of: 'pipx', 'uv-tool', 'uvx', 'uv', 'conda', 'pip', or 'unknown'. + 'pipx', 'uv-tool', 'uvx', 'uv' (uv run), and 'conda' are high-confidence + detections. 'uv-tool' means `uv tool install` (persistent); 'uvx' means + `uvx` / `uv tool run` (ephemeral) — the two require different injection + commands, so they must not be conflated. """ probe = probe or SystemProbe() hint = _install_method_hint(probe) - if hint in {"pipx", "uvx", "uv", "conda", "pip", "unknown"}: + if hint in {"pipx", "uv-tool", "uvx", "uv", "conda", "pip", "unknown"}: return hint # Check high-confidence detections first (runtime environment) if probe.is_pipx(): return "pipx" + if probe.is_uv_tool_install(): + return "uv-tool" if probe.is_uvx(): return "uvx" if probe.is_uv_run(): @@ -146,7 +151,14 @@ def shell_target(method: str) -> str: "pip": InstallOption("pip", f"pip install {shell_target('pip')}"), "pipx": InstallOption("pipx", f"pipx inject sqlit-tui {shell_target('pipx')}"), "uv": InstallOption("uv", f"uv pip install {shell_target('uv')}"), - "uvx": InstallOption("uvx", f"uvx --with {shell_target('uvx')} sqlit-tui"), + "uv-tool": InstallOption( + "uv-tool", + f"uv tool install --reinstall --with {shell_target('uv-tool')} sqlit-tui", + ), + "uvx": InstallOption( + "uvx", + f"uvx --from sqlit-tui --with {shell_target('uvx')} sqlit", + ), "poetry": InstallOption("poetry", f"poetry add {shell_target('poetry')}"), "pdm": InstallOption("pdm", f"pdm add {shell_target('pdm')}"), "conda": InstallOption("conda", f"conda install {shell_target('conda')}"), @@ -157,17 +169,19 @@ def shell_target(method: str) -> str: # Order based on detection - detected method first, then common alternatives if detected == "pipx": - order = ["pipx", "pip", "uv", "uvx", "poetry", "pdm", "conda"] + order = ["pipx", "pip", "uv", "uv-tool", "uvx", "poetry", "pdm", "conda"] + elif detected == "uv-tool": + order = ["uv-tool", "uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"] elif detected == "uvx": - order = ["uvx", "uv", "pip", "pipx", "poetry", "pdm", "conda"] + order = ["uvx", "uv-tool", "uv", "pip", "pipx", "poetry", "pdm", "conda"] elif detected == "uv": # uv run - prefer uv pip install - order = ["uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"] + order = ["uv", "pip", "uv-tool", "uvx", "pipx", "poetry", "pdm", "conda"] elif detected == "conda": - order = ["conda", "pip", "uv", "pipx", "uvx", "poetry", "pdm"] + order = ["conda", "pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm"] else: # Default: pip first - order = ["pip", "uv", "pipx", "uvx", "poetry", "pdm", "conda"] + order = ["pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm", "conda"] options = [all_options[key] for key in order] diff --git a/sqlit/shared/app/services.py b/sqlit/shared/app/services.py index 9dd5c764..1c4b7c97 100644 --- a/sqlit/shared/app/services.py +++ b/sqlit/shared/app/services.py @@ -62,7 +62,7 @@ def _normalize_install_method_hint(value: str | None) -> str | None: if not value: return None normalized = value.strip().lower() - if normalized in {"pipx", "pip", "unknown", "uvx", "uv", "conda"}: + if normalized in {"pipx", "pip", "unknown", "uv-tool", "uvx", "uv", "conda"}: return normalized return None diff --git a/sqlit/shared/core/system_probe.py b/sqlit/shared/core/system_probe.py index 5b1814fd..55cab029 100644 --- a/sqlit/shared/core/system_probe.py +++ b/sqlit/shared/core/system_probe.py @@ -92,10 +92,26 @@ def is_pipx(self) -> bool: exe = self._executable.lower() return "/pipx/venvs/" in exe or "\\pipx\\venvs\\" in exe - def is_uvx(self) -> bool: + def is_uv_tool_install(self) -> bool: + """True when launched from a `uv tool install` persistent environment.""" exe = self._executable.lower() return "/uv/tools/" in exe or "\\uv\\tools\\" in exe + def is_uvx(self) -> bool: + """True when launched from a `uvx` / `uv tool run` ephemeral environment. + + uvx envs live under the uv cache as `environments-v2///`, + backed by symlinks into `archive-v0/`. + """ + exe = self._executable.lower() + markers = ( + "/uv/environments-v2/", + "\\uv\\environments-v2\\", + "/uv/cache/archive-v0/", + "\\uv\\cache\\archive-v0\\", + ) + return any(m in exe for m in markers) + def is_uv_run(self) -> bool: return self._uv_env @@ -143,6 +159,7 @@ def executable(self) -> str: ... def in_venv(self) -> bool: ... def is_pipx(self) -> bool: ... + def is_uv_tool_install(self) -> bool: ... def is_uvx(self) -> bool: ... def is_uv_run(self) -> bool: ... def is_conda(self) -> bool: ... diff --git a/sqlit/shared/core/system_probe_fake.py b/sqlit/shared/core/system_probe_fake.py index 663b5090..bf60d898 100644 --- a/sqlit/shared/core/system_probe_fake.py +++ b/sqlit/shared/core/system_probe_fake.py @@ -54,6 +54,9 @@ def in_venv(self) -> bool: def is_pipx(self) -> bool: return self.install_method == "pipx" + def is_uv_tool_install(self) -> bool: + return self.install_method == "uv-tool" + def is_uvx(self) -> bool: return self.install_method == "uvx" diff --git a/tests/unit/test_install_strategy_uv.py b/tests/unit/test_install_strategy_uv.py new file mode 100644 index 00000000..f45d4537 --- /dev/null +++ b/tests/unit/test_install_strategy_uv.py @@ -0,0 +1,110 @@ +"""Tests for distinguishing `uv tool install` from `uvx` (issue #133). + +Background: both install flavors land executables under the uv cache/data +tree, but `uv tool install` is persistent and `uvx` is ephemeral. They +require different commands to add an extra dependency: + +- uv tool install → `uv tool install --reinstall --with X sqlit-tui` +- uvx → `uvx --from sqlit-tui --with X sqlit` + +The previous `is_uvx()` check (matching `/uv/tools/`) actually matched the +persistent path and would hand `uv tool install` users a broken uvx +command. +""" + +from __future__ import annotations + +from sqlit.domains.connections.app.install_strategy import ( + detect_install_method, + get_install_options, +) +from sqlit.shared.core.system_probe import SystemProbe + + +def _probe_with_exe(path: str) -> SystemProbe: + return SystemProbe( + env={"_SQLIT_TEST": "1"}, + executable=path, + prefix=path, + base_prefix=path, + pip_available=True, + ) + + +def test_uv_tool_install_path_detected_as_uv_tool() -> None: + probe = _probe_with_exe( + "/home/alice/.local/share/uv/tools/sqlit-tui/bin/python" + ) + assert probe.is_uv_tool_install() is True + assert probe.is_uvx() is False + assert detect_install_method(probe=probe) == "uv-tool" + + +def test_uvx_ephemeral_path_detected_as_uvx() -> None: + probe = _probe_with_exe( + "/home/alice/.cache/uv/environments-v2/7b6a360d9162862b/845f3c5907156215/bin/python" + ) + assert probe.is_uv_tool_install() is False + assert probe.is_uvx() is True + assert detect_install_method(probe=probe) == "uvx" + + +def test_uvx_legacy_archive_path_detected_as_uvx() -> None: + """Older uv caches reference `archive-v0/` directly; still ephemeral.""" + probe = _probe_with_exe( + "/home/alice/.cache/uv/cache/archive-v0/abcdef/bin/python" + ) + assert probe.is_uvx() is True + assert detect_install_method(probe=probe) == "uvx" + + +def test_uv_tool_install_option_uses_reinstall_with() -> None: + """Issue #133: a `uv tool install` user needs a persistent reinstall, + not `uvx --with ... sqlit-tui` (which is ephemeral and wrong syntax).""" + probe = _probe_with_exe( + "/home/alice/.local/share/uv/tools/sqlit-tui/bin/python" + ) + options = get_install_options( + package_name="PyMySQL", + extra_name="mysql", + probe=probe, + ) + detected = [opt for opt in options if opt.label == "uv-tool"] + assert detected, "uv-tool option must be present" + assert ( + detected[0].command + == "uv tool install --reinstall --with PyMySQL sqlit-tui" + ) + # First option is the detected one + assert options[0].label == "uv-tool" + + +def test_uvx_option_uses_from_flag_and_correct_executable() -> None: + """`uvx ` fails when the package and executable names differ. + Must be `uvx --from sqlit-tui --with ... sqlit`.""" + probe = _probe_with_exe( + "/home/alice/.cache/uv/environments-v2/abc/def/bin/python" + ) + options = get_install_options( + package_name="PyMySQL", + extra_name="mysql", + probe=probe, + ) + detected = [opt for opt in options if opt.label == "uvx"] + assert detected + cmd = detected[0].command + assert cmd.startswith("uvx --from sqlit-tui --with ") + assert cmd.endswith(" sqlit") + assert "sqlit-tui" != cmd.split()[-1], "executable must be `sqlit`, not `sqlit-tui`" + + +def test_previous_uvx_false_match_on_uv_tools_path_is_gone() -> None: + """Regression: the old is_uvx() matched `/uv/tools/` and misclassified + `uv tool install` users as uvx. Pin that exact case here so we don't + reintroduce the conflation.""" + probe = _probe_with_exe( + "/home/alice/.local/share/uv/tools/sqlit-tui/bin/python" + ) + assert probe.is_uvx() is False + # And the detected method is uv-tool, not uvx + assert detect_install_method(probe=probe) == "uv-tool"