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
84 changes: 84 additions & 0 deletions libs/localenv/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package localenv

import (
"os"
"path/filepath"
)

// pyprojectFile is the project file this command reads, merges, and writes; it
// is also the marker detectManager keys on to bias detection toward uv.
const pyprojectFile = "pyproject.toml"

// manager identifies the Python package manager a project uses. P0 only
// supports uv; every other value results in a clean E_MANAGER_UNSUPPORTED exit.
type manager string

const (
managerUv manager = "uv"
managerConda manager = "conda"
managerPip manager = "pip"
)

// detectManager inspects projectDir for package-manager markers, only deep
// enough to branch uv vs. not-uv (spec §5). It emits no telemetry (spec §5).
//
// Detection is deliberately biased toward uv, because uv's native project file
// is pyproject.toml (PEP 621) — the same format this command writes and merges:
// - A uv marker (uv.lock or a [tool.uv] table) → uv.
// - A pyproject.toml with no competing marker → uv (a plain PEP 621 project is
// exactly the "existing project merge" case; uv can drive it).
// - conda (environment.yml) or pip (requirements.txt) with no pyproject.toml →
// that manager; automated setup is P1, so the caller exits cleanly.
// - Greenfield (no markers at all) → uv, the manager this command provisions.
//
// A conda/pip marker that sits alongside a pyproject.toml still resolves to uv:
// the project already has the file we drive, so we proceed rather than block.
func detectManager(projectDir string) manager {
// uv markers take precedence: an existing uv project or lockfile.
if fileExists(filepath.Join(projectDir, "uv.lock")) {
return managerUv
}
if fileExists(filepath.Join(projectDir, pyprojectFile)) {
// A pyproject.toml — with or without a [tool.uv] table — is uv-drivable.
return managerUv
}

// No pyproject.toml: a conda or pip marker means a non-uv project we cannot
// yet automate. conda before pip (environment.yml is the more specific signal).
if fileExists(filepath.Join(projectDir, "environment.yml")) ||
fileExists(filepath.Join(projectDir, "environment.yaml")) {
return managerConda
}
if fileExists(filepath.Join(projectDir, "requirements.txt")) {
return managerPip
}

// Greenfield: nothing to disambiguate; this command provisions uv.
return managerUv
}

// managerGuidance returns the actionable, non-blaming message shown when a
// non-uv manager is detected (spec §5).
func managerGuidance(m manager) string {
return "detected a " + string(m) + " project; automated setup for " + string(m) +
" is not yet available (P1). Use a uv project (add a pyproject.toml with a [tool.uv] table, or run `uv init`) to provision automatically"
}

// fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

// ensureWritable verifies the process can create files in dir by creating and
// removing a temporary file. A permission failure is reported so preflight can
// stop before any real write (invariant 1).
func ensureWritable(dir string) error {
f, err := os.CreateTemp(dir, ".localenv-writecheck-*")
if err != nil {
return err
}
name := f.Name()
f.Close()
return os.Remove(name)
}
51 changes: 51 additions & 0 deletions libs/localenv/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package localenv

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDetectManager(t *testing.T) {
write := func(t *testing.T, files ...string) string {
dir := t.TempDir()
for _, f := range files {
require.NoError(t, os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644))
}
return dir
}

cases := []struct {
name string
files []string
want manager
}{
{"greenfield", nil, managerUv},
{"uv lock", []string{"uv.lock"}, managerUv},
{"plain pyproject", []string{"pyproject.toml"}, managerUv},
{"pyproject wins over requirements", []string{"pyproject.toml", "requirements.txt"}, managerUv},
{"conda only", []string{"environment.yml"}, managerConda},
{"conda yaml", []string{"environment.yaml"}, managerConda},
{"pip only", []string{"requirements.txt"}, managerPip},
{"conda before pip", []string{"environment.yml", "requirements.txt"}, managerConda},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, detectManager(write(t, tc.files...)))
})
}
}

func TestEnsureWritable(t *testing.T) {
assert.NoError(t, ensureWritable(t.TempDir()))
assert.Error(t, ensureWritable(filepath.Join(t.TempDir(), "does-not-exist")))
}

func TestManagerGuidance(t *testing.T) {
msg := managerGuidance(managerConda)
assert.Contains(t, msg, "conda")
assert.Contains(t, msg, "uv init")
}
31 changes: 31 additions & 0 deletions libs/localenv/pkgmanager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package localenv

import "context"

// PackageManager manages the Python environment for a dbconnect project.
type PackageManager interface {
// Name returns the name of the package manager (e.g. "uv").
Name() string

// EnsureAvailable ensures the package manager binary is present, installing
// it if necessary. It returns the version string on success.
EnsureAvailable(ctx context.Context) (version string, err error)

// EnsurePython ensures the requested Python minor version (e.g. "3.12") is
// available via the package manager.
EnsurePython(ctx context.Context, minor string) error

// Provision installs the project dependencies inside projectDir.
Provision(ctx context.Context, projectDir string) error

// PostProvision seeds pip into the virtual environment inside projectDir.
// This step is required because VS Code's ms-python.vscode-python-envs
// extension falls back to `python -m pip list` when its `uv --version`
// probe fails on the GUI PATH; uv venvs contain no pip; and `uv sync`
// strips pip, so seeding must run after every sync.
PostProvision(ctx context.Context, projectDir string) error

// Validate reads the Python minor version and databricks-connect version
// from the virtual environment inside projectDir.
Validate(ctx context.Context, projectDir string) (pythonVersion, dbconnectVersion string, err error)
}
Loading