Skip to content

Commit f7c9cee

Browse files
authored
[VPEX][5a] Add local-env package-manager interface, detection, and preflight (#5850)
## Why - PR #5828 (`[VPEX][5/8]`) reads as +3,466 / 15 files on GitHub, but that is an artifact: PRs 1–4 were squash-merged into `main` while `dbconnect/05-pipeline` still sits on the pre-merge parent, so GitHub re-counts all of the already-merged code. The genuinely-new content in that layer is +1,165 / 5 files. - This splits that real delta at its one clean dependency seam. This PR is the leaf half: the `PackageManager` interface, manager detection, and the writability preflight — none of which reference the pipeline orchestrator. ## What - **`pkgmanager.go`** — the `PackageManager` interface the pipeline provisions through (implemented by the uv backend in a later PR). - **`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 shown for an unsupported manager, and the `ensureWritable` preflight. - The `pyprojectFile` constant moves here from `pipeline.go`, since detection is its first consumer. ## Testing strategy - `detect_test.go` covers the detection matrix (greenfield, uv lock, plain pyproject, conda/pip precedence), `ensureWritable` on a writable and a non-existent dir, and the unsupported-manager guidance message. - Gates: `go build`, `go test`, `golangci-lint` (0 issues), `deadcode`, `gofmt` — all green. --- ## About this stack This is one of a series of small, stacked PRs that together add the `databricks local-env python sync` command. It supersedes the pipeline half of #5828, split further at the interface/detection seam. **Review bottom-up.** This PR and #5b (the six-phase pipeline orchestrator, stacked on this one) together replace #5828. This pull request and its description were written by Isaac.
1 parent 88aa451 commit f7c9cee

3 files changed

Lines changed: 166 additions & 0 deletions

File tree

libs/localenv/detect.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package localenv
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
)
7+
8+
// pyprojectFile is the project file this command reads, merges, and writes; it
9+
// is also the marker detectManager keys on to bias detection toward uv.
10+
const pyprojectFile = "pyproject.toml"
11+
12+
// manager identifies the Python package manager a project uses. P0 only
13+
// supports uv; every other value results in a clean E_MANAGER_UNSUPPORTED exit.
14+
type manager string
15+
16+
const (
17+
managerUv manager = "uv"
18+
managerConda manager = "conda"
19+
managerPip manager = "pip"
20+
)
21+
22+
// detectManager inspects projectDir for package-manager markers, only deep
23+
// enough to branch uv vs. not-uv (spec §5). It emits no telemetry (spec §5).
24+
//
25+
// Detection is deliberately biased toward uv, because uv's native project file
26+
// is pyproject.toml (PEP 621) — the same format this command writes and merges:
27+
// - A uv marker (uv.lock or a [tool.uv] table) → uv.
28+
// - A pyproject.toml with no competing marker → uv (a plain PEP 621 project is
29+
// exactly the "existing project merge" case; uv can drive it).
30+
// - conda (environment.yml) or pip (requirements.txt) with no pyproject.toml →
31+
// that manager; automated setup is P1, so the caller exits cleanly.
32+
// - Greenfield (no markers at all) → uv, the manager this command provisions.
33+
//
34+
// A conda/pip marker that sits alongside a pyproject.toml still resolves to uv:
35+
// the project already has the file we drive, so we proceed rather than block.
36+
func detectManager(projectDir string) manager {
37+
// uv markers take precedence: an existing uv project or lockfile.
38+
if fileExists(filepath.Join(projectDir, "uv.lock")) {
39+
return managerUv
40+
}
41+
if fileExists(filepath.Join(projectDir, pyprojectFile)) {
42+
// A pyproject.toml — with or without a [tool.uv] table — is uv-drivable.
43+
return managerUv
44+
}
45+
46+
// No pyproject.toml: a conda or pip marker means a non-uv project we cannot
47+
// yet automate. conda before pip (environment.yml is the more specific signal).
48+
if fileExists(filepath.Join(projectDir, "environment.yml")) ||
49+
fileExists(filepath.Join(projectDir, "environment.yaml")) {
50+
return managerConda
51+
}
52+
if fileExists(filepath.Join(projectDir, "requirements.txt")) {
53+
return managerPip
54+
}
55+
56+
// Greenfield: nothing to disambiguate; this command provisions uv.
57+
return managerUv
58+
}
59+
60+
// managerGuidance returns the actionable, non-blaming message shown when a
61+
// non-uv manager is detected (spec §5).
62+
func managerGuidance(m manager) string {
63+
return "detected a " + string(m) + " project; automated setup for " + string(m) +
64+
" 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"
65+
}
66+
67+
// fileExists reports whether path exists and is a regular file.
68+
func fileExists(path string) bool {
69+
info, err := os.Stat(path)
70+
return err == nil && !info.IsDir()
71+
}
72+
73+
// ensureWritable verifies the process can create files in dir by creating and
74+
// removing a temporary file. A permission failure is reported so preflight can
75+
// stop before any real write (invariant 1).
76+
func ensureWritable(dir string) error {
77+
f, err := os.CreateTemp(dir, ".localenv-writecheck-*")
78+
if err != nil {
79+
return err
80+
}
81+
name := f.Name()
82+
f.Close()
83+
return os.Remove(name)
84+
}

libs/localenv/detect_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package localenv
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestDetectManager(t *testing.T) {
13+
write := func(t *testing.T, files ...string) string {
14+
dir := t.TempDir()
15+
for _, f := range files {
16+
require.NoError(t, os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644))
17+
}
18+
return dir
19+
}
20+
21+
cases := []struct {
22+
name string
23+
files []string
24+
want manager
25+
}{
26+
{"greenfield", nil, managerUv},
27+
{"uv lock", []string{"uv.lock"}, managerUv},
28+
{"plain pyproject", []string{"pyproject.toml"}, managerUv},
29+
{"pyproject wins over requirements", []string{"pyproject.toml", "requirements.txt"}, managerUv},
30+
{"conda only", []string{"environment.yml"}, managerConda},
31+
{"conda yaml", []string{"environment.yaml"}, managerConda},
32+
{"pip only", []string{"requirements.txt"}, managerPip},
33+
{"conda before pip", []string{"environment.yml", "requirements.txt"}, managerConda},
34+
}
35+
for _, tc := range cases {
36+
t.Run(tc.name, func(t *testing.T) {
37+
assert.Equal(t, tc.want, detectManager(write(t, tc.files...)))
38+
})
39+
}
40+
}
41+
42+
func TestEnsureWritable(t *testing.T) {
43+
assert.NoError(t, ensureWritable(t.TempDir()))
44+
assert.Error(t, ensureWritable(filepath.Join(t.TempDir(), "does-not-exist")))
45+
}
46+
47+
func TestManagerGuidance(t *testing.T) {
48+
msg := managerGuidance(managerConda)
49+
assert.Contains(t, msg, "conda")
50+
assert.Contains(t, msg, "uv init")
51+
}

libs/localenv/pkgmanager.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package localenv
2+
3+
import "context"
4+
5+
// PackageManager manages the Python environment for a dbconnect project.
6+
type PackageManager interface {
7+
// Name returns the name of the package manager (e.g. "uv").
8+
Name() string
9+
10+
// EnsureAvailable ensures the package manager binary is present, installing
11+
// it if necessary. It returns the version string on success.
12+
EnsureAvailable(ctx context.Context) (version string, err error)
13+
14+
// EnsurePython ensures the requested Python minor version (e.g. "3.12") is
15+
// available via the package manager.
16+
EnsurePython(ctx context.Context, minor string) error
17+
18+
// Provision installs the project dependencies inside projectDir.
19+
Provision(ctx context.Context, projectDir string) error
20+
21+
// PostProvision seeds pip into the virtual environment inside projectDir.
22+
// This step is required because VS Code's ms-python.vscode-python-envs
23+
// extension falls back to `python -m pip list` when its `uv --version`
24+
// probe fails on the GUI PATH; uv venvs contain no pip; and `uv sync`
25+
// strips pip, so seeding must run after every sync.
26+
PostProvision(ctx context.Context, projectDir string) error
27+
28+
// Validate reads the Python minor version and databricks-connect version
29+
// from the virtual environment inside projectDir.
30+
Validate(ctx context.Context, projectDir string) (pythonVersion, dbconnectVersion string, err error)
31+
}

0 commit comments

Comments
 (0)