diff --git a/CLAUDE.md b/CLAUDE.md index 1e46721..dd37676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,11 +223,11 @@ 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 the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`) +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 the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-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 an optional leading `v`; blank lines and `#` comments are skipped. A partial version resolves to the highest installed match. Anything else (e.g. `.nvmrc` aliases like `lts/*`), 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). +**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); blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). A partial version resolves to the highest installed match. 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). **Config format** (both local and global): ```json diff --git a/README.md b/README.md index c3d55a5..046cfc5 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 `.node-version` and `.nvmrc` for Node.js +✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` / `.nvmrc` for Node.js and `.python-version` for Python ✅ **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 2b2c536..0e28445 100644 --- a/src/cmd/current.go +++ b/src/cmd/current.go @@ -29,9 +29,10 @@ var currentCmd = &cobra.Command{ Short: "Show the currently active version(s)", Long: `Show the currently active version for a specific runtime or all runtimes. The active version is determined by checking local settings first, then global settings. -Local settings are read from .dtvem/runtimes.json and, for Node.js, from -.node-version and .nvmrc, in the current directory or the nearest parent that -has one. The Source column shows which file set each version. +Local settings are read from .dtvem/runtimes.json and from each runtime's own +version files (Node.js: .node-version and .nvmrc; Python: .python-version), in +the current directory or the nearest parent that has one. The Source column +shows which file set each version. Examples: dtvem current # Show all active versions diff --git a/src/internal/config/version.go b/src/internal/config/version.go index 30f466a..b4eb03a 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -88,9 +88,10 @@ func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { return Resolution{Version: version, Source: runtimesFile}, true, nil } - for _, name := range versionFiles[runtimeName] { + rules := versionFiles[runtimeName] + for _, name := range rules.names { path := filepath.Join(dir, name) - spec, err := readVersionSpec(path) + spec, err := readVersionSpec(path, rules) if errors.Is(err, fs.ErrNotExist) { continue } diff --git a/src/internal/config/versionfile.go b/src/internal/config/versionfile.go index 0be01bc..23f5a72 100644 --- a/src/internal/config/versionfile.go +++ b/src/internal/config/versionfile.go @@ -9,11 +9,22 @@ import ( "github.com/CodingWithCalvin/dtvem.cli/src/internal/version" ) -// versionFiles lists, for each runtime, the files other tools in its -// ecosystem use to pin a project's version. Within one directory they are -// consulted after .dtvem/runtimes.json, in the order listed. -var versionFiles = map[string][]string{ - "node": {".node-version", ".nvmrc"}, +// versionFiles describes, for each runtime, the files other tools in its +// ecosystem use to pin a project's version. +var versionFiles = map[string]versionFileRules{ + "node": {names: []string{".node-version", ".nvmrc"}, prefix: "v"}, + "python": {names: []string{".python-version"}, prefix: "python-"}, +} + +// versionFileRules describes a runtime's version files and how versions are +// written in them. +type versionFileRules struct { + // names are the files, consulted after .dtvem/runtimes.json in the + // order listed when several are in one directory. + names []string + // prefix is an optional prefix written before a version, such as the + // "v" in "v22.12.0" or the "python-" in "python-3.12.4". + prefix string } // VersionFileError reports a version file whose contents dtvem cannot use. @@ -26,45 +37,49 @@ func (e *VersionFileError) Error() string { if e.Value == "" { return fmt.Sprintf("%s does not contain a version", e.Path) } - return fmt.Sprintf("%s asks for %q: only version numbers (x, x.y or x.y.z) are supported, not aliases", e.Path, e.Value) + return fmt.Sprintf("%s asks for %q: dtvem supports only version numbers (x, x.y or x.y.z)", e.Path, e.Value) } -// readVersionSpec reads a version file such as .node-version or .nvmrc and -// returns the version it asks for. The error satisfies +// readVersionSpec reads a version file such as .node-version or +// .python-version and returns the version it asks for. The error satisfies // errors.Is(err, fs.ErrNotExist) when the file does not exist. -func readVersionSpec(path string) (string, error) { +func readVersionSpec(path string, rules versionFileRules) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("could not read %s: %w", path, err) } - return parseVersionSpec(path, string(data)) + return parseVersionSpec(path, string(data), rules) } // parseVersionSpec extracts the version from the contents of the version -// file at path. It skips blank lines and # comments, takes the first -// remaining line, and drops a leading "v". The result is an exact -// ("22.12.0") or partial ("22", "22.12") version number; anything else, -// such as an .nvmrc alias like "lts/*", is a *VersionFileError. -func parseVersionSpec(path, content string) (string, error) { +// file at path. It skips blank lines and # comments and takes the first +// word of the first remaining line, as pyenv and rbenv do. See +// versionFileRules.version for the forms accepted. +func parseVersionSpec(path, content string, rules versionFileRules) (string, error) { for _, line := range strings.Split(content, "\n") { if i := strings.IndexByte(line, '#'); i >= 0 { line = line[:i] } - line = strings.TrimSpace(line) - if line == "" { - continue - } - - spec := strings.TrimPrefix(line, "v") - if !isVersionNumber(spec) { - return "", &VersionFileError{Path: path, Value: line} + if fields := strings.Fields(line); len(fields) > 0 { + return rules.version(path, fields[0]) } - return spec, nil } return "", &VersionFileError{Path: path} } +// version returns the version raw asks for, without the runtime's prefix: +// an exact ("22.12.0") or partial ("22", "22.12") version number. Anything +// else, such as an .nvmrc alias like "lts/*" or pyenv's "system", is a +// *VersionFileError for the file at path. +func (r versionFileRules) version(path, raw string) (string, error) { + spec := strings.TrimPrefix(raw, r.prefix) + if !isVersionNumber(spec) { + return "", &VersionFileError{Path: path, Value: raw} + } + return spec, nil +} + // isVersionNumber reports whether s is one to three dot-separated runs of // digits. func isVersionNumber(s string) bool { diff --git a/src/internal/config/versionfile_test.go b/src/internal/config/versionfile_test.go index 688bbf6..27e1da6 100644 --- a/src/internal/config/versionfile_test.go +++ b/src/internal/config/versionfile_test.go @@ -25,11 +25,12 @@ func TestParseVersionSpec(t *testing.T) { {name: "comment line before version", content: "# pinned for CI\n22.12.0\n", want: "22.12.0"}, {name: "inline comment", content: "22 # current LTS\n", want: "22"}, {name: "first version line wins", content: "22.12.0\n20.0.0\n", want: "22.12.0"}, + {name: "first word of the line wins", content: "22.12.0 20.0.0\n", want: "22.12.0"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := parseVersionSpec(".node-version", tt.content) + got, err := parseVersionSpec(".node-version", tt.content, versionFiles["node"]) if err != nil { t.Fatalf("parseVersionSpec(%q) unexpected error: %v", tt.content, err) } @@ -57,11 +58,12 @@ func TestParseVersionSpec_Unsupported(t *testing.T) { {name: "empty component", content: "22..1", wantValue: "22..1"}, {name: "bare v", content: "v", wantValue: "v"}, {name: "prerelease", content: "23.0.0-rc.1", wantValue: "23.0.0-rc.1"}, + {name: "another runtime's prefix", content: "python-22", wantValue: "python-22"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := parseVersionSpec(".nvmrc", tt.content) + _, err := parseVersionSpec(".nvmrc", tt.content, versionFiles["node"]) var fileErr *VersionFileError if !errors.As(err, &fileErr) { @@ -79,7 +81,7 @@ func TestParseVersionSpec_Unsupported(t *testing.T) { func TestVersionFileError_Message(t *testing.T) { alias := (&VersionFileError{Path: ".nvmrc", Value: "lts/*"}).Error() - if want := `.nvmrc asks for "lts/*": only version numbers (x, x.y or x.y.z) are supported, not aliases`; alias != want { + if want := `.nvmrc asks for "lts/*": dtvem supports only version numbers (x, x.y or x.y.z)`; alias != want { t.Errorf("Error() = %q, want %q", alias, want) } @@ -89,6 +91,63 @@ func TestVersionFileError_Message(t *testing.T) { } } +func TestParseVersionSpec_Python(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "exact version", content: "3.12.4\n", want: "3.12.4"}, + {name: "partial version", content: "3.12", want: "3.12"}, + {name: "python- prefix", content: "python-3.12.4", want: "3.12.4"}, + {name: "comment and blank lines", content: "# pinned by uv\n\n3.12.4\n", want: "3.12.4"}, + {name: "first of several versions", content: "3.12.4\n3.11.9\n", want: "3.12.4"}, + {name: "CRLF line endings", content: "3.12.4\r\n", want: "3.12.4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseVersionSpec(".python-version", tt.content, versionFiles["python"]) + if err != nil { + t.Fatalf("parseVersionSpec(%q) unexpected error: %v", tt.content, err) + } + if got != tt.want { + t.Errorf("parseVersionSpec(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestParseVersionSpec_PythonUnsupported(t *testing.T) { + tests := []struct { + name string + content string + wantValue string + }{ + {name: "system", content: "system\n", wantValue: "system"}, + {name: "pyenv-virtualenv pointer", content: "3.10/envs/venv", wantValue: "3.10/envs/venv"}, + {name: "virtualenv name", content: "my-venv", wantValue: "my-venv"}, + {name: "uv implementation request", content: "pypy", wantValue: "pypy"}, + {name: "uv implementation@version request", content: "cpython@3.12", wantValue: "cpython@3.12"}, + {name: "v prefix is not Python's", content: "v3.12.4", wantValue: "v3.12.4"}, + {name: "empty file", content: "\n", wantValue: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseVersionSpec(".python-version", tt.content, versionFiles["python"]) + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("parseVersionSpec(%q) error = %v, want *VersionFileError", tt.content, err) + } + if fileErr.Value != tt.wantValue { + t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, tt.wantValue) + } + }) + } +} + // versionFixture is a dtvem root and a project directory to resolve from. type versionFixture struct { t *testing.T @@ -400,12 +459,66 @@ func TestResolve_VersionFilesAreRuntimeSpecific(t *testing.T) { f := newVersionFixture(t) f.write(".node-version", "22.12.0") f.write(".nvmrc", "lts/*") + f.write(".python-version", "system") f.chdir(".") - _, err := Resolve("python") - if !errors.Is(err, ErrNoVersion) { - t.Errorf("Resolve(\"python\") error = %v, want ErrNoVersion", err) + if _, err := Resolve("ruby"); !errors.Is(err, ErrNoVersion) { + t.Errorf("Resolve(\"ruby\") error = %v, want ErrNoVersion", err) + } + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve(\"node\") error: %v", err) + } + if res.Version != "22.12.0" { + t.Errorf("Resolve(\"node\") = %q, want %q", res.Version, "22.12.0") + } + + var fileErr *VersionFileError + if _, err := Resolve("python"); !errors.As(err, &fileErr) || filepath.Base(fileErr.Path) != ".python-version" { + t.Errorf("Resolve(\"python\") error = %v, want a *VersionFileError for .python-version", err) + } +} + +func TestResolve_PythonVersionFile(t *testing.T) { + f := newVersionFixture(t) + f.install("python", "3.11.9", "3.12.4", "3.12.12", "3.13.1") + source := f.write(".python-version", "3.12\n") + f.chdir(filepath.Join("src", "pkg")) + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) } + assertResolution(t, res, "3.12.12", source) +} + +func TestResolve_PythonPrecedence(t *testing.T) { + t.Run("runtimes.json before .python-version", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(filepath.Join(".dtvem", "runtimes.json"), `{"python": "3.11.9"}`) + f.write(".python-version", "3.12.4") + f.chdir(".") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.11.9", source) + }) + + t.Run("nearer .python-version over parent runtimes.json", func(t *testing.T) { + f := newVersionFixture(t) + f.write(filepath.Join(".dtvem", "runtimes.json"), `{"python": "3.11.9"}`) + source := f.write(filepath.Join("app", ".python-version"), "python-3.12.4") + f.chdir("app") + + res, err := Resolve("python") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "3.12.4", source) + }) } func TestResolveVersion_UsesVersionFiles(t *testing.T) {