From f24c32062d82432108dd24e01fa6aba569dd0dc7 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Wed, 8 Jul 2026 11:50:38 +0200 Subject: [PATCH] Add local-env package-manager interface, detection, and preflight Split from the pipeline layer so the PackageManager seam (implemented by the uv backend in a later PR), manager detection, and the writability preflight land as a small, independently reviewable unit. - pkgmanager.go: the PackageManager interface the pipeline provisions through. - detect.go: uv-vs-not-uv detection (biased toward uv, whose native project file is the pyproject.toml this command drives), the non-blaming guidance message for unsupported managers, and the ensureWritable preflight. Co-authored-by: Isaac --- libs/localenv/detect.go | 84 ++++++++++++++++++++++++++++++++++++ libs/localenv/detect_test.go | 51 ++++++++++++++++++++++ libs/localenv/pkgmanager.go | 31 +++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 libs/localenv/detect.go create mode 100644 libs/localenv/detect_test.go create mode 100644 libs/localenv/pkgmanager.go diff --git a/libs/localenv/detect.go b/libs/localenv/detect.go new file mode 100644 index 00000000000..8c58c19ec43 --- /dev/null +++ b/libs/localenv/detect.go @@ -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) +} diff --git a/libs/localenv/detect_test.go b/libs/localenv/detect_test.go new file mode 100644 index 00000000000..45eb6655ee2 --- /dev/null +++ b/libs/localenv/detect_test.go @@ -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") +} diff --git a/libs/localenv/pkgmanager.go b/libs/localenv/pkgmanager.go new file mode 100644 index 00000000000..fff370c76fc --- /dev/null +++ b/libs/localenv/pkgmanager.go @@ -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) +}