Skip to content

Commit ab83a9c

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 ab83a9c

5 files changed

Lines changed: 163 additions & 12 deletions

File tree

sqlit/domains/connections/app/install_strategy.py

Lines changed: 31 additions & 10 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():
@@ -141,12 +146,26 @@ def target_for(method: str) -> str:
141146
def shell_target(method: str) -> str:
142147
return _format_shell_target(target_for(method))
143148

144-
# All available options
149+
# All available options.
150+
#
151+
# uv-tool vs uvx: `uv tool install` is persistent, so adding a driver
152+
# requires reinstalling the tool with the extra bundled in — that's the
153+
# --reinstall --with recipe below. `uvx` is ephemeral, so the same
154+
# invocation that runs sqlit also brings the extra along for that run.
155+
# Critically, `uvx --from sqlit-tui` is required because the executable
156+
# inside the sqlit-tui package is named `sqlit`, not `sqlit-tui`.
145157
all_options = {
146158
"pip": InstallOption("pip", f"pip install {shell_target('pip')}"),
147159
"pipx": InstallOption("pipx", f"pipx inject sqlit-tui {shell_target('pipx')}"),
148160
"uv": InstallOption("uv", f"uv pip install {shell_target('uv')}"),
149-
"uvx": InstallOption("uvx", f"uvx --with {shell_target('uvx')} sqlit-tui"),
161+
"uv-tool": InstallOption(
162+
"uv-tool",
163+
f"uv tool install --reinstall --with {shell_target('uv-tool')} sqlit-tui",
164+
),
165+
"uvx": InstallOption(
166+
"uvx",
167+
f"uvx --from sqlit-tui --with {shell_target('uvx')} sqlit",
168+
),
150169
"poetry": InstallOption("poetry", f"poetry add {shell_target('poetry')}"),
151170
"pdm": InstallOption("pdm", f"pdm add {shell_target('pdm')}"),
152171
"conda": InstallOption("conda", f"conda install {shell_target('conda')}"),
@@ -157,17 +176,19 @@ def shell_target(method: str) -> str:
157176

158177
# Order based on detection - detected method first, then common alternatives
159178
if detected == "pipx":
160-
order = ["pipx", "pip", "uv", "uvx", "poetry", "pdm", "conda"]
179+
order = ["pipx", "pip", "uv", "uv-tool", "uvx", "poetry", "pdm", "conda"]
180+
elif detected == "uv-tool":
181+
order = ["uv-tool", "uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"]
161182
elif detected == "uvx":
162-
order = ["uvx", "uv", "pip", "pipx", "poetry", "pdm", "conda"]
183+
order = ["uvx", "uv-tool", "uv", "pip", "pipx", "poetry", "pdm", "conda"]
163184
elif detected == "uv":
164185
# uv run - prefer uv pip install
165-
order = ["uv", "pip", "uvx", "pipx", "poetry", "pdm", "conda"]
186+
order = ["uv", "pip", "uv-tool", "uvx", "pipx", "poetry", "pdm", "conda"]
166187
elif detected == "conda":
167-
order = ["conda", "pip", "uv", "pipx", "uvx", "poetry", "pdm"]
188+
order = ["conda", "pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm"]
168189
else:
169190
# Default: pip first
170-
order = ["pip", "uv", "pipx", "uvx", "poetry", "pdm", "conda"]
191+
order = ["pip", "uv", "pipx", "uv-tool", "uvx", "poetry", "pdm", "conda"]
171192

172193
options = [all_options[key] for key in order]
173194

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)