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
32 changes: 23 additions & 9 deletions sqlit/domains/connections/app/install_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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')}"),
Expand All @@ -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]

Expand Down
2 changes: 1 addition & 1 deletion sqlit/shared/app/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion sqlit/shared/core/system_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>/<subhash>/`,
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

Expand Down Expand Up @@ -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: ...
Expand Down
3 changes: 3 additions & 0 deletions sqlit/shared/core/system_probe_fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
110 changes: 110 additions & 0 deletions tests/unit/test_install_strategy_uv.py
Original file line number Diff line number Diff line change
@@ -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 <package>` 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"
Loading