diff --git a/CLAUDE.md b/CLAUDE.md index c646c86..137c3cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,12 +223,14 @@ The shim mappings are automatically registered via `Shims()`. ## Version Resolution **Priority order:** -1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then asdf/mise `.tool-versions`, then the runtime's own version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`; Ruby: `.ruby-version`). A `runtimes.json` or `.tool-versions` with no entry for the runtime is passed over, so it never hides a pin further up. The walk also reaches files in `$HOME`, so an asdf user's `~/.tool-versions` (or a `~/.nvmrc`) applies everywhere under the home directory, ahead of the global version +1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then asdf/mise `.tool-versions`, then the runtime's own version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`; Ruby: `.ruby-version`), and for Python last of all `.venv/pyvenv.cfg` (`internal/config/venv.go`). A `runtimes.json` or `.tool-versions` with no entry for the runtime is passed over, so it never hides a pin further up. The walk also reaches files in `$HOME`, so an asdf user's `~/.tool-versions` (or a `~/.nvmrc`) applies everywhere under the home directory, ahead of the global version 2. **Global**: `~/.dtvem/config/runtimes.json` 3. **Error**: No version configured (`config.ErrNoVersion`; the shim falls back to the system PATH) **Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python, `ruby-` for Ruby); Ruby also accepts an exact pre-release (`4.0.0-preview2`, `3.4.0-rc1`). Blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). In `.tool-versions` (` ...` lines), the runtime's line is found by tool name (`nodejs` or `node`, `python`, `ruby`; other tools are ignored) and its first version is used, under the same rules; `system`, `ref:` and `path:` are unsupported. A partial version resolves to the highest installed match; a release ranks above its own pre-releases (`internal/version`), so `4.0` picks `4.0.0` over `4.0.0-preview2`. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it). +**Python `.venv`**: a project's `.venv/pyvenv.cfg` is read when nothing else in its directory pins Python. dtvem runs its own install of the version the venv was built from (`version`, else uv's `version_info`), not the venv's interpreter, so packages still need an activated venv or `uv run`. It prefers the exact version, else the highest installed with the same major.minor (compiled packages depend on the minor version); with none installed it reports the major.minor as not installed. A `.venv` without `pyvenv.cfg`, a `.venv` that is a file, and a `.venv` in the home directory (a user-wide environment, not a project's) are passed over. + **Config format** (both local and global): ```json { diff --git a/README.md b/README.md index 4ee6060..ac54422 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Managing multiple versions of Python, Node.js, Ruby, and other runtimes across d ✅ **Migration Tool**: Import existing installations from nvm, pyenv, etc. -✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus asdf/mise `.tool-versions`, `.node-version` / `.nvmrc` for Node.js, `.python-version` for Python, and `.ruby-version` for Ruby +✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus asdf/mise `.tool-versions`, `.node-version` / `.nvmrc` for Node.js, `.python-version` (or the version a project's `.venv` was built from) for Python, and `.ruby-version` for Ruby ✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc. diff --git a/src/cmd/current.go b/src/cmd/current.go index 2fe10fa..a661794 100644 --- a/src/cmd/current.go +++ b/src/cmd/current.go @@ -32,8 +32,9 @@ The active version is determined by checking local settings first, then global s Local settings are read from .dtvem/runtimes.json, asdf/mise .tool-versions, and each runtime's own version files (Node.js: .node-version and .nvmrc; Python: .python-version; Ruby: .ruby-version), in the current directory or -the nearest parent that has one. The Source column shows which file set each -version. +the nearest parent that has one. For Python, a project's .venv also counts: +when nothing else in its directory sets a version, the Python the venv was +built from is used. The Source column shows which file set each version. Examples: dtvem current # Show all active versions diff --git a/src/internal/config/venv.go b/src/internal/config/venv.go new file mode 100644 index 0000000..5d471c0 --- /dev/null +++ b/src/internal/config/venv.go @@ -0,0 +1,131 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/version" +) + +// venvDir is the directory uv and most tools create a project's virtual +// environment in. +const venvDir = ".venv" + +// venvConfigFile is the file in a virtual environment that records the +// Python it was built from. +const venvConfigFile = "pyvenv.cfg" + +// resolveVenv returns the Python version the virtual environment in dir's +// .venv was built from. The bool is false when dir has no project virtual +// environment: no .venv directory, a .venv without a pyvenv.cfg, or a .venv +// in the home directory, which is a user-wide environment rather than a +// project's. +func resolveVenv(dir, runtimeName string) (Resolution, bool, error) { + venv := filepath.Join(dir, venvDir) + info, err := os.Stat(venv) + if errors.Is(err, fs.ErrNotExist) { + return Resolution{}, false, nil + } + if err != nil { + return Resolution{}, false, fmt.Errorf("could not read %s: %w", venv, err) + } + if !info.IsDir() || isHomeDir(dir) { + return Resolution{}, false, nil + } + + path := filepath.Join(venv, venvConfigFile) + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return Resolution{}, false, nil + } + if err != nil { + return Resolution{}, false, fmt.Errorf("could not read %s: %w", path, err) + } + + venvVersion, err := parseVenvVersion(path, string(data)) + if err != nil { + return Resolution{}, false, err + } + return Resolution{Version: matchVenvVersion(runtimeName, venvVersion), Source: path}, true, nil +} + +// parseVenvVersion returns the version of the Python a virtual environment +// was built from, given the contents of its pyvenv.cfg at path. It reads the +// version key, which python -m venv writes, falling back to version_info, +// which uv writes, and keeps the leading major.minor[.patch]: a value can +// carry more, as in "3.12.1.final.0" or "3.13.0rc1". +func parseVenvVersion(path, content string) (string, error) { + values := make(map[string]string) + for _, line := range strings.Split(content, "\n") { + if key, value, ok := strings.Cut(line, "="); ok { + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + + raw := values["version"] + if raw == "" { + raw = values["version_info"] + } + if raw == "" { + return "", &VersionFileError{Path: path} + } + + var parts []string + for _, part := range strings.Split(raw, ".") { + digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))] + if digits == "" { + break + } + parts = append(parts, digits) + if len(parts) == 3 || digits != part { + break + } + } + if len(parts) < 2 { + return "", &VersionFileError{Path: path, Value: raw} + } + return strings.Join(parts, "."), nil +} + +// matchVenvVersion returns the installed version to use for a virtual +// environment built from venvVersion: that exact version if it is +// installed, otherwise the highest installed version with the same +// major.minor, since a virtual environment's compiled packages depend on +// the minor version rather than the patch. When nothing installed shares +// the major.minor, it returns the major.minor, so callers report it as not +// installed and suggest installing that line. +func matchVenvVersion(runtimeName, venvVersion string) string { + installed := installedVersions(runtimeName) + if slices.Contains(installed, venvVersion) { + return venvVersion + } + + parts := strings.Split(venvVersion, ".") + minor := parts[0] + "." + parts[1] + if resolved, err := version.ResolvePartialVersion(minor, installed); err == nil { + return resolved + } + return minor +} + +// isHomeDir reports whether dir is the user's home directory. +func isHomeDir(dir string) bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + homeInfo, err := os.Stat(home) + if err != nil { + return false + } + dirInfo, err := os.Stat(dir) + if err != nil { + return false + } + return os.SameFile(homeInfo, dirInfo) +} diff --git a/src/internal/config/venv_test.go b/src/internal/config/venv_test.go new file mode 100644 index 0000000..9984742 --- /dev/null +++ b/src/internal/config/venv_test.go @@ -0,0 +1,210 @@ +package config + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestParseVenvVersion(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "python -m venv", + content: "home = C:\\Users\\me\\.dtvem\\versions\\python\\3.12.12\ninclude-system-site-packages = false\nversion = 3.12.12\n", + want: "3.12.12", + }, + {name: "uv", content: "home = /home/me/.dtvem/versions/python/3.9.9/bin\nimplementation = CPython\nuv = 0.9.22\nversion_info = 3.9.9\n", want: "3.9.9"}, + {name: "version preferred over version_info", content: "version_info = 3.11.9\nversion = 3.12.4\n", want: "3.12.4"}, + {name: "version_info with release level", content: "version_info = 3.12.1.final.0\n", want: "3.12.1"}, + {name: "pre-release Python", content: "version = 3.13.0rc1\n", want: "3.13.0"}, + {name: "no spaces around =", content: "version=3.12.4", want: "3.12.4"}, + {name: "CRLF", content: "home = C:\\Python312\r\nversion = 3.12.4\r\n", want: "3.12.4"}, + {name: "major.minor only", content: "version = 3.12\n", want: "3.12"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseVenvVersion("pyvenv.cfg", tt.content) + if err != nil { + t.Fatalf("parseVenvVersion(%q) unexpected error: %v", tt.content, err) + } + if got != tt.want { + t.Errorf("parseVenvVersion(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestParseVenvVersion_Unusable(t *testing.T) { + tests := []struct { + name string + content string + wantValue string + }{ + {name: "empty", content: "", wantValue: ""}, + {name: "no version key", content: "home = /usr/bin\ninclude-system-site-packages = false\n", wantValue: ""}, + {name: "empty version", content: "version = \n", wantValue: ""}, + {name: "not a version", content: "version = unknown\n", wantValue: "unknown"}, + {name: "major only", content: "version = 3\n", wantValue: "3"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseVenvVersion("pyvenv.cfg", tt.content) + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("parseVenvVersion(%q) error = %v, want *VersionFileError", tt.content, err) + } + if fileErr.Value != tt.wantValue { + t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, tt.wantValue) + } + }) + } +} + +func TestResolve_Venv(t *testing.T) { + tests := []struct { + name string + venvVersion string + want string + }{ + {name: "exact version installed", venvVersion: "3.12.4", want: "3.12.4"}, + {name: "other patch picks highest of the same minor", venvVersion: "3.12.7", want: "3.12.12"}, + {name: "no installed version of the minor", venvVersion: "3.11.9", want: "3.11"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newVersionFixture(t) + f.install("python", "3.12.4", "3.12.12", "3.13.1") + source := f.write(filepath.Join(".venv", "pyvenv.cfg"), "home = /opt/python\nversion = "+tt.venvVersion+"\n") + f.chdir(filepath.Join("src", "pkg")) + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, tt.want, source) + }) + } +} + +func TestResolve_VenvPrecedence(t *testing.T) { + venv := filepath.Join(".venv", "pyvenv.cfg") + + tests := []struct { + name string + pin string // file that pins the version beside the .venv + content string + }{ + {name: "runtimes.json before .venv", pin: filepath.Join(".dtvem", "runtimes.json"), content: `{"python": "3.13.1"}`}, + {name: ".tool-versions before .venv", pin: ".tool-versions", content: "python 3.13.1\n"}, + {name: ".python-version before .venv", pin: ".python-version", content: "3.13.1\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newVersionFixture(t) + f.install("python", "3.12.12", "3.13.1") + f.write(venv, "version = 3.12.12\n") + source := f.write(tt.pin, tt.content) + f.chdir(".") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.13.1", source) + }) + } + + t.Run("nearer .venv over parent .python-version", func(t *testing.T) { + f := newVersionFixture(t) + f.install("python", "3.12.12", "3.13.1") + f.write(".python-version", "3.13.1\n") + source := f.write(filepath.Join("service", ".venv", "pyvenv.cfg"), "version = 3.12.12\n") + f.chdir("service") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.12.12", source) + }) +} + +func TestResolve_VenvPassedOver(t *testing.T) { + t.Run(".venv without pyvenv.cfg", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".python-version", "3.13.1\n") + f.write(filepath.Join("app", ".venv", "Scripts", "python.exe"), "") + f.chdir("app") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.13.1", source) + }) + + t.Run(".venv that is a file", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".python-version", "3.13.1\n") + f.write(filepath.Join("app", ".venv"), "my-virtualenv\n") + f.chdir("app") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.13.1", source) + }) + + t.Run(".venv in the home directory", func(t *testing.T) { + f := newVersionFixture(t) + t.Setenv("HOME", f.project) + t.Setenv("USERPROFILE", f.project) + if err := SetGlobalVersion("python", "3.13.1"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.write(filepath.Join(".venv", "pyvenv.cfg"), "version = 3.11.9\n") + f.chdir("code") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.13.1", GlobalConfigPath()) + }) + + t.Run("other runtimes ignore .venv", func(t *testing.T) { + f := newVersionFixture(t) + f.write(filepath.Join(".venv", "pyvenv.cfg"), "version = 3.12.12\n") + f.chdir(".") + + for _, runtimeName := range []string{"node", "ruby"} { + if _, err := Resolve(runtimeName); !errors.Is(err, ErrNoVersion) { + t.Errorf("Resolve(%q) error = %v, want ErrNoVersion", runtimeName, err) + } + } + }) +} + +func TestResolve_VenvWithoutVersion(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("python", "3.13.1"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.write(filepath.Join(".venv", "pyvenv.cfg"), "home = /usr/bin\n") + f.chdir(".") + + var fileErr *VersionFileError + if _, err := Resolve("python"); !errors.As(err, &fileErr) || filepath.Base(fileErr.Path) != "pyvenv.cfg" { + t.Errorf("Resolve() error = %v, want a *VersionFileError for pyvenv.cfg", err) + } +} diff --git a/src/internal/config/version.go b/src/internal/config/version.go index feb26be..bb0a9dc 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -79,11 +79,11 @@ func findLocal(runtimeName string) (Resolution, bool, error) { // resolveInDir returns the version dir sets for runtimeName, if any. It // checks .dtvem/runtimes.json first, then .tool-versions, then the -// runtime's own version files (see versionFiles). A runtimes.json that is -// unreadable or lacks the runtime, or a .tool-versions without a line for -// it, is passed over; a version file that names a version dtvem cannot use -// is an error, so a project's pin is never silently replaced by another -// version. +// runtime's own version files (see versionFiles), and for Python finally +// the version a .venv was built from. A runtimes.json that is unreadable or +// lacks the runtime, or a .tool-versions without a line for it, is passed +// over; a version file that names a version dtvem cannot use is an error, +// so a project's pin is never silently replaced by another version. func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { runtimesFile := filepath.Join(dir, LocalConfigDirName, RuntimesFileName) if version, err := readVersionFile(runtimesFile, runtimeName); err == nil && version != "" { @@ -114,6 +114,9 @@ func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { return Resolution{Version: resolveSpec(runtimeName, spec), Source: path}, true, nil } + if rules.venv { + return resolveVenv(dir, runtimeName) + } return Resolution{}, false, nil } diff --git a/src/internal/config/versionfile.go b/src/internal/config/versionfile.go index f4ee2e5..6472ce8 100644 --- a/src/internal/config/versionfile.go +++ b/src/internal/config/versionfile.go @@ -14,7 +14,7 @@ import ( // ecosystem use to pin a project's version. var versionFiles = map[string]versionFileRules{ "node": {names: []string{".node-version", ".nvmrc"}, toolNames: []string{"nodejs", "node"}, prefix: "v"}, - "python": {names: []string{".python-version"}, toolNames: []string{"python"}, prefix: "python-"}, + "python": {names: []string{".python-version"}, toolNames: []string{"python"}, prefix: "python-", venv: true}, "ruby": {names: []string{".ruby-version"}, toolNames: []string{"ruby"}, prefix: "ruby-", prereleases: true}, } @@ -38,6 +38,9 @@ type versionFileRules struct { // prereleases allows an exact version to name a pre-release, such as // Ruby's "4.0.0-preview2" or "3.4.0-rc1". prereleases bool + // venv reads the version a project's .venv was built from (see + // resolveVenv) when nothing else in the directory sets one. + venv bool } // VersionFileError reports a version file whose contents dtvem cannot use.