Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<tool> <version>...` 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
{
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions src/cmd/current.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions src/internal/config/venv.go
Original file line number Diff line number Diff line change
@@ -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)
}
210 changes: 210 additions & 0 deletions src/internal/config/venv_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading