@@ -12,27 +12,27 @@ import (
1212 "runtime"
1313 "strings"
1414
15+ "github.com/databricks/cli/libs/cmdio"
1516 "github.com/databricks/cli/libs/env"
1617 "github.com/databricks/cli/libs/log"
1718 "github.com/databricks/cli/libs/process"
1819)
1920
21+ // EnvAutoInstallUv opts into installing uv without an interactive prompt. It
22+ // exists so non-interactive runs (CI, IDE integrations) can allow the install
23+ // that would otherwise be declined for lack of a TTY. Any truthy value enables it.
24+ const EnvAutoInstallUv = "DATABRICKS_LOCALENV_AUTO_INSTALL_UV"
25+
2026// uvManager implements PackageManager using the uv tool.
2127// https://docs.astral.sh/uv/
2228type uvManager struct {
2329 bin string
2430}
2531
26- // newUvManager returns a uvManager whose binary path is resolved lazily via
27- // EnsureAvailable.
28- func newUvManager () * uvManager {
29- return & uvManager {}
30- }
31-
32- // NewUvManager returns a PackageManager backed by the uv tool.
33- // This is the exported constructor for use outside this package.
32+ // NewUvManager returns a PackageManager backed by the uv tool. The binary path
33+ // is resolved lazily via EnsureAvailable.
3434func NewUvManager () PackageManager {
35- return newUvManager ()
35+ return & uvManager {}
3636}
3737
3838// Name returns "uv".
@@ -47,6 +47,10 @@ func (m *uvManager) Name() string {
4747func (m * uvManager ) EnsureAvailable (ctx context.Context ) (string , error ) {
4848 bin , err := discoverUv (ctx )
4949 if err != nil {
50+ if ! confirmUvInstall (ctx ) {
51+ return "" , NewError (ErrUvMissing , nil ,
52+ "uv is required but not installed; install it (https://docs.astral.sh/uv/getting-started/installation/) or set %s=1 to let this command install it for you" , EnvAutoInstallUv )
53+ }
5054 if installErr := installUv (ctx ); installErr != nil {
5155 return "" , NewError (ErrUvMissing , installErr , "uv installation failed" )
5256 }
@@ -66,17 +70,24 @@ func (m *uvManager) EnsureAvailable(ctx context.Context) (string, error) {
6670 return strings .TrimSpace (version ), nil
6771}
6872
73+ // runUv runs the uv binary with args in dir, injecting UV_INDEX_URL from pip.conf
74+ // when appropriate. An empty dir runs in the current working directory
75+ // (process.WithDir("") is a no-op). The index-url is injected only when
76+ // resolveIndexURL returns non-empty; it returns "" when UV_INDEX_URL is already
77+ // set, so an explicit value in the environment is never clobbered.
78+ func (m * uvManager ) runUv (ctx context.Context , args []string , dir string ) error {
79+ if indexURL := m .resolveIndexURL (ctx ); indexURL != "" {
80+ _ , err := process .Background (ctx , args , process .WithDir (dir ), process .WithEnv ("UV_INDEX_URL" , indexURL ))
81+ return err
82+ }
83+ _ , err := process .Background (ctx , args , process .WithDir (dir ))
84+ return err
85+ }
86+
6987// EnsurePython installs the requested Python minor version via uv.
7088func (m * uvManager ) EnsurePython (ctx context.Context , minor string ) error {
7189 args := append ([]string {m .bin }, m .pythonInstallArgs (minor )... )
72- indexURL := m .resolveIndexURL (ctx )
73- var err error
74- if indexURL != "" {
75- _ , err = process .Background (ctx , args , process .WithEnv ("UV_INDEX_URL" , indexURL ))
76- } else {
77- _ , err = process .Background (ctx , args )
78- }
79- if err != nil {
90+ if err := m .runUv (ctx , args , "" ); err != nil {
8091 return uvFailure (ErrPythonInstall , err , "uv python install " + minor )
8192 }
8293 return nil
@@ -85,14 +96,7 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor string) error {
8596// Provision runs `uv sync` inside projectDir to install project dependencies.
8697func (m * uvManager ) Provision (ctx context.Context , projectDir string ) error {
8798 args := append ([]string {m .bin }, m .syncArgs ()... )
88- indexURL := m .resolveIndexURL (ctx )
89- var err error
90- if indexURL != "" {
91- _ , err = process .Background (ctx , args , process .WithDir (projectDir ), process .WithEnv ("UV_INDEX_URL" , indexURL ))
92- } else {
93- _ , err = process .Background (ctx , args , process .WithDir (projectDir ))
94- }
95- if err != nil {
99+ if err := m .runUv (ctx , args , projectDir ); err != nil {
96100 return uvFailure (ErrProvision , err , "uv sync" )
97101 }
98102 return nil
@@ -117,14 +121,7 @@ func venvPython(projectDir string) string {
117121// activated.
118122func (m * uvManager ) PostProvision (ctx context.Context , projectDir string ) error {
119123 args := append ([]string {m .bin }, m .pipSeedArgs (venvPython (projectDir ))... )
120- indexURL := m .resolveIndexURL (ctx )
121- var err error
122- if indexURL != "" {
123- _ , err = process .Background (ctx , args , process .WithDir (projectDir ), process .WithEnv ("UV_INDEX_URL" , indexURL ))
124- } else {
125- _ , err = process .Background (ctx , args , process .WithDir (projectDir ))
126- }
127- if err != nil {
124+ if err := m .runUv (ctx , args , projectDir ); err != nil {
128125 return uvFailure (ErrProvision , err , "uv pip seed" )
129126 }
130127 return nil
@@ -136,12 +133,16 @@ func (m *uvManager) PostProvision(ctx context.Context, projectDir string) error
136133// error: PackageNotFoundError is caught so the probe never fails just because the
137134// package is absent. The caller decides whether an empty version is acceptable.
138135func (m * uvManager ) Validate (ctx context.Context , projectDir string ) (string , string , error ) {
136+ // Each value is printed with a unique prefix so parsing greps for the prefix
137+ // rather than relying on line position: any stray line uv or the interpreter
138+ // writes to stdout (e.g. a warning) would otherwise shift a positional parse.
139+ // A missing databricks-connect prints an empty DBC: value, not an error.
139140 pyCode := `import sys, importlib.metadata
140- print(f"{sys.version_info.major}.{sys.version_info.minor}")
141+ print(f"` + validatePyPrefix + ` {sys.version_info.major}.{sys.version_info.minor}")
141142try:
142- print(importlib.metadata.version("databricks-connect"))
143+ print("` + validateDBCPrefix + `" + importlib.metadata.version("databricks-connect"))
143144except importlib.metadata.PackageNotFoundError:
144- print("")`
145+ print("` + validateDBCPrefix + ` ")`
145146 // --no-project runs the interpreter from the created .venv without re-resolving/syncing
146147 // the project's declared dependencies, so validation observes exactly what was installed.
147148 out , err := process .Background (ctx ,
@@ -151,16 +152,32 @@ except importlib.metadata.PackageNotFoundError:
151152 if err != nil {
152153 return "" , "" , uvFailure (ErrValidate , err , "uv run python validation" )
153154 }
154- lines := strings . Split ( strings . TrimSpace ( out ), " \n " )
155- if len ( lines ) < 1 || strings . TrimSpace ( lines [ 0 ]) == "" {
155+ pyVer , ok := lineWithPrefix ( out , validatePyPrefix )
156+ if ! ok || pyVer == "" {
156157 return "" , "" , NewError (ErrValidate , nil , "unexpected output from uv run: %q" , out )
157158 }
158- // The databricks-connect line is empty when the package is not installed.
159- dbcVer := ""
160- if len (lines ) >= 2 {
161- dbcVer = strings .TrimSpace (lines [len (lines )- 1 ])
159+ // The databricks-connect value is empty when the package is not installed.
160+ dbcVer , _ := lineWithPrefix (out , validateDBCPrefix )
161+ return pyVer , dbcVer , nil
162+ }
163+
164+ // Validation output prefixes: uv run's stdout is grepped for these rather than
165+ // parsed positionally, so extra lines from uv or the interpreter don't break it.
166+ const (
167+ validatePyPrefix = "PYVER:"
168+ validateDBCPrefix = "DBCVER:"
169+ )
170+
171+ // lineWithPrefix returns the trimmed remainder of the first line in out that
172+ // starts with prefix, and whether such a line was found.
173+ func lineWithPrefix (out , prefix string ) (string , bool ) {
174+ for _ , line := range strings .Split (out , "\n " ) {
175+ line = strings .TrimSpace (line )
176+ if after , ok := strings .CutPrefix (line , prefix ); ok {
177+ return strings .TrimSpace (after ), true
178+ }
162179 }
163- return strings . TrimSpace ( lines [ 0 ]), dbcVer , nil
180+ return "" , false
164181}
165182
166183// syncArgs returns the argument slice for `uv sync` (without the binary).
@@ -291,6 +308,22 @@ func uvFailure(code ErrorCode, err error, action string) *PipelineError {
291308 return NewError (code , err , "%s" , msg )
292309}
293310
311+ // confirmUvInstall reports whether the caller has consented to installUv running
312+ // a remote installer that mutates the machine. The EnvAutoInstallUv opt-in wins
313+ // outright (for CI / IDE integrations); otherwise an interactive session is
314+ // prompted, and a non-interactive session without the opt-in declines rather than
315+ // silently downloading and executing an installer.
316+ func confirmUvInstall (ctx context.Context ) bool {
317+ if optIn , ok := env .GetBool (ctx , EnvAutoInstallUv ); ok && optIn {
318+ return true
319+ }
320+ if ! cmdio .IsPromptSupported (ctx ) {
321+ return false
322+ }
323+ ok , err := cmdio .AskYesOrNo (ctx , "uv is not installed. Download and run the official uv installer (https://astral.sh/uv/install.sh)?" )
324+ return err == nil && ok
325+ }
326+
294327// installUv runs the official uv installer for the current OS. Unix uses the
295328// shell installer; Windows uses the PowerShell installer, because the Unix
296329// `sh`/`curl` pipeline is not available in a default Windows shell.
0 commit comments