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

import (
"os"
"path/filepath"
)

// 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)
}
45 changes: 45 additions & 0 deletions libs/localenv/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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")))
}
Loading
Loading