Skip to content

Commit aca23b4

Browse files
committed
Distinguish uv tool install from uvx and emit correct install commands
Issue #133: a user on `uv tool install sqlit-tui` hits the missing-driver dialog, picks the "(Detected)" option, and gets `uvx --with PyMySQL sqlit-tui` — which fails with "An executable named `sqlit-tui` is not provided by package `sqlit-tui`." Even if the syntax were fixed to `uvx --from sqlit-tui --with PyMySQL sqlit`, that would only spawn an ephemeral sqlit; his persistent install is still driver-less. Root cause: `SystemProbe.is_uvx()` was matching `/uv/tools/`, which is the `uv tool install` path — not the uvx cache path. Every persistent install was misclassified as uvx, and the emitted uvx command was syntactically broken to boot (sqlit-tui is the package name, sqlit is the executable inside it). Fix: - `is_uvx()` now matches `/uv/environments-v2/` and `/uv/cache/archive-v0/`, where uvx actually stores ephemeral envs. - New `is_uv_tool_install()` matches `/uv/tools/` and gets its own `"uv-tool"` detection bucket, with command `uv tool install --reinstall --with X sqlit-tui` (the only way to add a dependency to a persistent uv tool install today — see astral-sh/uv#14746). - The uvx command is corrected to `uvx --from sqlit-tui --with X sqlit`. Closes #133.
1 parent a132802 commit aca23b4

5 files changed

Lines changed: 155 additions & 11 deletions

File tree

sqlit/domains/connections/app/install_strategy.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,23 @@ class InstallOption:
103103
def detect_install_method(*, probe: SystemProbeProtocol | None = None) -> str:
104104
"""Detect how sqlit was installed/is running.
105105
106-
Returns one of: 'pipx', 'uvx', 'uv', 'conda', 'pip', or 'unknown'.
107-
'pipx', 'uvx', 'uv' (uv run), and 'conda' are high-confidence detections.
106+
Returns one of: 'pipx', 'uv-tool', 'uvx', 'uv', 'conda', 'pip', or 'unknown'.
107+
'pipx', 'uv-tool', 'uvx', 'uv' (uv run), and 'conda' are high-confidence
108+
detections. 'uv-tool' means `uv tool install` (persistent); 'uvx' means
109+
`uvx` / `uv tool run` (ephemeral) — the two require different injection
110+
commands, so they must not be conflated.
108111
"""
109112
probe = probe or SystemProbe()
110113

111114
hint = _install_method_hint(probe)
112-
if hint in {"pipx", "uvx", "uv", "conda", "pip", "unknown"}:
115+
if hint in {"pipx", "uv-tool", "uvx", "uv", "conda", "pip", "unknown"}:
113116
return hint
114117

115118
# Check high-confidence detections first (runtime environment)
116119
if probe.is_pipx():
117120
return "pipx"
121+
if probe.is_uv_tool_install():
122+
return "uv-tool"
118123
if probe.is_uvx():
119124
return "uvx"
120125
if probe.is_uv_run():
@@ -146,7 +151,14 @@ def shell_target(method: str) -> str:
146151
"pip": InstallOption("pip", f"pip install {shell_target('pip')}"),
147152
"pipx": InstallOption("pipx", f"pipx inject sqlit-tui {shell_target('pipx')}"),
148153
"uv": InstallOption("uv", f"uv pip install {shell_target('uv')}"),
149-
"uvx": InstallOption("uvx", f"uvx --with {shell_target('uvx')} sqlit-tui"),
154+
"uv-tool": InstallOption(
155+
"uv-tool",
156+
f"uv tool install --reinstall --with {shell_target('uv-tool')} sqlit-tui",
157+
),
158+
"uvx": InstallOption(
159+
"uvx",
160+
f"uvx --from sqlit-tui --with {shell_target('uvx')} sqlit",
161+
),
150162
"poetry": InstallOption("poetry", f"poetry add {shell_target('poetry')}"),
151163
"pdm": InstallOption("pdm", f"pdm add {shell_target('pdm')}"),
152164
"conda": InstallOption("conda", f"conda install {shell_target('conda')}"),
@@ -157,17 +169,19 @@ def shell_target(method: str) -> str:
157169

158170
# Order based on detection - detected method first, then common alternatives
159171
if detected == "pipx":
160-
order = ["pipx", "pip", "uv", "uvx", "poetry", "pdm", "conda"]
172+
order = ["pipx", "pip", "uv", "uv-tool", "uvx", "poetry", "pdm", "conda"]
173+
elif detected == "uv-tool":
174+
order = ["uv-tool", "uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"]
161175
elif detected == "uvx":
162-
order = ["uvx", "uv", "pip", "pipx", "poetry", "pdm", "conda"]
176+
order = ["uvx", "uv-tool", "uv", "pip", "pipx", "poetry", "pdm", "conda"]
163177
elif detected == "uv":
164178
# uv run - prefer uv pip install
165-
order = ["uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"]
179+
order = ["uv", "pip", "uv-tool", "uvx", "pipx", "poetry", "pdm", "conda"]
166180
elif detected == "conda":
167-
order = ["conda", "pip", "uv", "pipx", "uvx", "poetry", "pdm"]
181+
order = ["conda", "pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm"]
168182
else:
169183
# Default: pip first
170-
order = ["pip", "uv", "pipx", "uvx", "poetry", "pdm", "conda"]
184+
order = ["pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm", "conda"]
171185

172186
options = [all_options[key] for key in order]
173187

sqlit/shared/app/services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def _normalize_install_method_hint(value: str | None) -> str | None:
6262
if not value:
6363
return None
6464
normalized = value.strip().lower()
65-
if normalized in {"pipx", "pip", "unknown", "uvx", "uv", "conda"}:
65+
if normalized in {"pipx", "pip", "unknown", "uv-tool", "uvx", "uv", "conda"}:
6666
return normalized
6767
return None
6868

sqlit/shared/core/system_probe.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,26 @@ def is_pipx(self) -> bool:
9292
exe = self._executable.lower()
9393
return "/pipx/venvs/" in exe or "\\pipx\\venvs\\" in exe
9494

95-
def is_uvx(self) -> bool:
95+
def is_uv_tool_install(self) -> bool:
96+
"""True when launched from a `uv tool install` persistent environment."""
9697
exe = self._executable.lower()
9798
return "/uv/tools/" in exe or "\\uv\\tools\\" in exe
9899

100+
def is_uvx(self) -> bool:
101+
"""True when launched from a `uvx` / `uv tool run` ephemeral environment.
102+
103+
uvx envs live under the uv cache as `environments-v2/<hash>/<subhash>/`,
104+
backed by symlinks into `archive-v0/`.
105+
"""
106+
exe = self._executable.lower()
107+
markers = (
108+
"/uv/environments-v2/",
109+
"\\uv\\environments-v2\\",
110+
"/uv/cache/archive-v0/",
111+
"\\uv\\cache\\archive-v0\\",
112+
)
113+
return any(m in exe for m in markers)
114+
99115
def is_uv_run(self) -> bool:
100116
return self._uv_env
101117

@@ -143,6 +159,7 @@ def executable(self) -> str: ...
143159

144160
def in_venv(self) -> bool: ...
145161
def is_pipx(self) -> bool: ...
162+
def is_uv_tool_install(self) -> bool: ...
146163
def is_uvx(self) -> bool: ...
147164
def is_uv_run(self) -> bool: ...
148165
def is_conda(self) -> bool: ...

sqlit/shared/core/system_probe_fake.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ def in_venv(self) -> bool:
5454
def is_pipx(self) -> bool:
5555
return self.install_method == "pipx"
5656

57+
def is_uv_tool_install(self) -> bool:
58+
return self.install_method == "uv-tool"
59+
5760
def is_uvx(self) -> bool:
5861
return self.install_method == "uvx"
5962

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for distinguishing `uv tool install` from `uvx` (issue #133).
2+
3+
Background: both install flavors land executables under the uv cache/data
4+
tree, but `uv tool install` is persistent and `uvx` is ephemeral. They
5+
require different commands to add an extra dependency:
6+
7+
- uv tool install → `uv tool install --reinstall --with X sqlit-tui`
8+
- uvx → `uvx --from sqlit-tui --with X sqlit`
9+
10+
The previous `is_uvx()` check (matching `/uv/tools/`) actually matched the
11+
persistent path and would hand `uv tool install` users a broken uvx
12+
command.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from sqlit.domains.connections.app.install_strategy import (
18+
detect_install_method,
19+
get_install_options,
20+
)
21+
from sqlit.shared.core.system_probe import SystemProbe
22+
23+
24+
def _probe_with_exe(path: str) -> SystemProbe:
25+
return SystemProbe(
26+
env={"_SQLIT_TEST": "1"},
27+
executable=path,
28+
prefix=path,
29+
base_prefix=path,
30+
pip_available=True,
31+
)
32+
33+
34+
def test_uv_tool_install_path_detected_as_uv_tool() -> None:
35+
probe = _probe_with_exe(
36+
"/home/alice/.local/share/uv/tools/sqlit-tui/bin/python"
37+
)
38+
assert probe.is_uv_tool_install() is True
39+
assert probe.is_uvx() is False
40+
assert detect_install_method(probe=probe) == "uv-tool"
41+
42+
43+
def test_uvx_ephemeral_path_detected_as_uvx() -> None:
44+
probe = _probe_with_exe(
45+
"/home/alice/.cache/uv/environments-v2/7b6a360d9162862b/845f3c5907156215/bin/python"
46+
)
47+
assert probe.is_uv_tool_install() is False
48+
assert probe.is_uvx() is True
49+
assert detect_install_method(probe=probe) == "uvx"
50+
51+
52+
def test_uvx_legacy_archive_path_detected_as_uvx() -> None:
53+
"""Older uv caches reference `archive-v0/` directly; still ephemeral."""
54+
probe = _probe_with_exe(
55+
"/home/alice/.cache/uv/cache/archive-v0/abcdef/bin/python"
56+
)
57+
assert probe.is_uvx() is True
58+
assert detect_install_method(probe=probe) == "uvx"
59+
60+
61+
def test_uv_tool_install_option_uses_reinstall_with() -> None:
62+
"""Issue #133: a `uv tool install` user needs a persistent reinstall,
63+
not `uvx --with ... sqlit-tui` (which is ephemeral and wrong syntax)."""
64+
probe = _probe_with_exe(
65+
"/home/alice/.local/share/uv/tools/sqlit-tui/bin/python"
66+
)
67+
options = get_install_options(
68+
package_name="PyMySQL",
69+
extra_name="mysql",
70+
probe=probe,
71+
)
72+
detected = [opt for opt in options if opt.label == "uv-tool"]
73+
assert detected, "uv-tool option must be present"
74+
assert (
75+
detected[0].command
76+
== "uv tool install --reinstall --with PyMySQL sqlit-tui"
77+
)
78+
# First option is the detected one
79+
assert options[0].label == "uv-tool"
80+
81+
82+
def test_uvx_option_uses_from_flag_and_correct_executable() -> None:
83+
"""`uvx <package>` fails when the package and executable names differ.
84+
Must be `uvx --from sqlit-tui --with ... sqlit`."""
85+
probe = _probe_with_exe(
86+
"/home/alice/.cache/uv/environments-v2/abc/def/bin/python"
87+
)
88+
options = get_install_options(
89+
package_name="PyMySQL",
90+
extra_name="mysql",
91+
probe=probe,
92+
)
93+
detected = [opt for opt in options if opt.label == "uvx"]
94+
assert detected
95+
cmd = detected[0].command
96+
assert cmd.startswith("uvx --from sqlit-tui --with ")
97+
assert cmd.endswith(" sqlit")
98+
assert "sqlit-tui" != cmd.split()[-1], "executable must be `sqlit`, not `sqlit-tui`"
99+
100+
101+
def test_previous_uvx_false_match_on_uv_tools_path_is_gone() -> None:
102+
"""Regression: the old is_uvx() matched `/uv/tools/` and misclassified
103+
`uv tool install` users as uvx. Pin that exact case here so we don't
104+
reintroduce the conflation."""
105+
probe = _probe_with_exe(
106+
"/home/alice/.local/share/uv/tools/sqlit-tui/bin/python"
107+
)
108+
assert probe.is_uvx() is False
109+
# And the detected method is uv-tool, not uvx
110+
assert detect_install_method(probe=probe) == "uv-tool"

0 commit comments

Comments
 (0)