feat: basic use,install,list,help,init functionality (#1)

* feat: basic use,install,list,help,init functionality

* docs: README notes

* refactor: argument parser documentation + removing redundant comments

* feat: add uninstall command

* feat: command ordering

* build: shortcut tooling to build, format

* feat: improve messaging around install progress

* refactor: rename argparse utils

* docs: more README notes
This commit is contained in:
Marc 2023-11-03 00:41:05 -04:00 committed by GitHub
parent 55e652d484
commit 7a1912c02a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 579 additions and 0 deletions

1
.gitignore vendored
View file

@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
v
# Test binary, built with `go test -c`
*.test

View file

@ -1,2 +1,35 @@
# v
A version manager you might not want to use.
## Overview
`v` is a simple version manager inspired from other tools like [asdf](https://github.com/asdf-vm/asdf), [pyenv](https://github.com/pyenv/pyenv), [n](https://github.com/tj/n) and [nvm](https://github.com/nvm-sh/nvm). At it's core, it's a reinvention of the wheel with some extras.
- First and foremost, while the first version is about Python version management, the plan is to expand to support a bunch more runtime (with an emphasis on simplifying adding more runtimes to manage);
- A lot of those tools are written as shellscript, which I find somewhat inscrutable. Go is a bit easier to read;
- ...? It's a reason to write some Go. :)
## Roadmap
While the plan for the first release is to only support Python runtimes, expanding to others will be next so that `v` can just handle all/most version management needs.
## Usage
### Building your own and setting up
Pre-built binaries are not currently available. You can clone the repository and build your own via `. scripts/build`.
You should find a suitable place for the binary (`/usr/local/bin` is a good location) and if not already included, add its location to `$PATH`.
Finally, run `v init` to create directories to store artifacts and state (under `~/.v` unless override using the
`V_ROOT` environment variable) and add `~/.v/shims` to your `$PATH` as well.
### Usage
`v` will print a helpful list of available commands.
The most important things to know include `v install <version>` to install new versions and `v use <installed version>` to use a specific version of Python.
## Contributing
The project isn't currently accepting contributions because it's not yet set up to do so. Stay tuned.

45
cmd/v.go Normal file
View file

@ -0,0 +1,45 @@
package main
import (
"os"
argparse "v/internal/argparse"
stateManager "v/internal/state"
subcommands "v/internal/subcommands"
)
const (
Version = "0.0.1"
Author = "mcataford <hello@karnov.club>"
)
// Main entrypoint.
func main() {
args := os.Args[1:]
currentState := stateManager.ReadState()
cli := argparse.CLI{
Metadata: map[string]string{
"Version": Version,
},
}
err := cli.AddCommand(
"install", subcommands.InstallPython, "v install <version>", "Downloads, builds and installs a new version of Python.",
).AddCommand(
"uninstall", subcommands.UninstallPython, "v uninstall <version>", "Uninstalls the given Python version.",
).AddCommand(
"use", subcommands.Use, "v use <version>", "Selects which Python version to use.",
).AddCommand(
"ls", subcommands.ListVersions, "v ls", "Lists the installed Python versions.",
).AddCommand(
"where", subcommands.Where, "v where", "Prints the path to the current Python version.",
).AddCommand(
"which", subcommands.Which, "v which", "Prints the current Python version.",
).AddCommand(
"init", subcommands.Initialize, "v init", "Initializes the v state.",
).Run(args, currentState)
if err != nil {
panic(err)
}
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module v
go 1.21.1

78
internal/argparse/cli.go Normal file
View file

@ -0,0 +1,78 @@
package argparse
import (
"fmt"
"strings"
stateManager "v/internal/state"
)
// Command definition for CLI subcommands.
type Command struct {
Label string
Handler func([]string, Flags, stateManager.State) error
Usage string
Description string
}
// Represents a CLI invocation.
// Must be initialized with commands via AddCommand before running
// with Run.
type CLI struct {
// Commands in enumeration order.
OrderedCommands []string
// Command metadata entries.
Commands map[string]Command
Metadata map[string]string
}
// Registers a command.
// This specifies a label that is used to route the user input to
// the right command, a handler that is called when the label is used,
// and usage/description details that get included in autogenerated help messaging.
func (c CLI) AddCommand(label string, handler func([]string, Flags, stateManager.State) error, usage string, description string) CLI {
if c.Commands == nil {
c.Commands = map[string]Command{}
c.OrderedCommands = []string{}
}
c.OrderedCommands = append(c.OrderedCommands, label)
c.Commands[label] = Command{Label: label, Handler: handler, Usage: usage, Description: description}
return c
}
// Executes one of the registered commands if any match the provided
// user arguments.
func (c CLI) Run(args []string, currentState stateManager.State) error {
if len(args) == 0 {
c.Help()
return nil
}
command := args[0]
if command == "help" {
c.Help()
return nil
}
flags := CollectFlags(args)
return c.Commands[command].Handler(args, flags, currentState)
}
// Prints autogenerated help documentation specifying command usage
// and descriptions based on registered commands (see: AddCommand).
func (c CLI) Help() {
usageStrings := []string{}
for _, commandLabel := range c.OrderedCommands {
command := c.Commands[commandLabel]
usageStrings = append(usageStrings, fmt.Sprintf("\033[1m%-30s\033[0m%s", command.Usage, command.Description))
}
helpString := fmt.Sprintf(`v: A simple version manager. (v%s)
---
%s`, c.Metadata["Version"], strings.Join(usageStrings, "\n"))
fmt.Println(helpString)
}

25
internal/argparse/util.go Normal file
View file

@ -0,0 +1,25 @@
package argparse
import (
"strings"
)
type Flags struct {
Verbose bool
}
func CollectFlags(args []string) Flags {
collected := Flags{}
for _, arg := range args {
if !strings.HasPrefix(arg, "--") {
continue
}
if arg == "--verbose" {
collected.Verbose = true
}
}
return collected
}

61
internal/state/state.go Normal file
View file

@ -0,0 +1,61 @@
package state
import (
"encoding/json"
"io/ioutil"
"os"
"path"
"strings"
)
// Persistent state used by the CLI to track runtime information
// between calls.
type State struct {
GlobalVersion string `json:"globalVersion"`
}
func GetStateDirectory() string {
home, _ := os.UserHomeDir()
userDefinedRoot, found := os.LookupEnv("V_ROOT")
root := path.Join(home, ".v")
if found {
root = userDefinedRoot
}
return root
}
func GetPathFromStateDirectory(suffix string) string {
return path.Join(GetStateDirectory(), suffix)
}
func ReadState() State {
c, _ := ioutil.ReadFile(GetPathFromStateDirectory("state.json"))
state := State{}
json.Unmarshal(c, &state)
return state
}
func WriteState(version string) {
state := State{GlobalVersion: version}
d, _ := json.Marshal(state)
ioutil.WriteFile(GetPathFromStateDirectory("state.json"), d, 0750)
}
func GetAvailableVersions() []string {
entries, _ := os.ReadDir(GetPathFromStateDirectory("runtimes"))
versions := []string{}
for _, d := range entries {
versions = append(versions, strings.TrimPrefix(d.Name(), "py-"))
}
return versions
}

View file

@ -0,0 +1,47 @@
package subcommands
import (
"os"
"path"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
var DIRECTORIES = []string{
"cache",
"runtimes",
"shims",
}
var SHIMS = []string{
"python",
"python3",
}
const DEFAULT_PERMISSION = 0775
func writeShim(shimPath string) error {
shimContent := []byte("#!/bin/bash\n$(vm where) $@")
if err := os.WriteFile(shimPath, shimContent, DEFAULT_PERMISSION); err != nil {
return err
}
return nil
}
// Sets up directories and files used to store downloaded archives,
// installed runtimes and metadata.
func Initialize(args []string, flags argparse.Flags, currentState stateManager.State) error {
stateDirectory := stateManager.GetStateDirectory()
os.Mkdir(stateDirectory, DEFAULT_PERMISSION)
for _, dir := range DIRECTORIES {
os.Mkdir(stateManager.GetPathFromStateDirectory(dir), DEFAULT_PERMISSION)
}
for _, shim := range SHIMS {
writeShim(stateManager.GetPathFromStateDirectory(path.Join("shims", shim)))
}
return nil
}

View file

@ -0,0 +1,122 @@
package subcommands
import (
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
argparse "v/internal/argparse"
stateManager "v/internal/state"
util "v/internal/util"
)
var pythonReleasesBaseURL = "https://www.python.org/ftp/python"
type PackageMetadata struct {
ArchivePath string
InstallPath string
Version string
}
type VersionTag struct {
Major string
Minor string
Patch string
}
func InstallPython(args []string, flags argparse.Flags, currentState stateManager.State) error {
verbose := flags.Verbose
version := args[1]
if err := validateVersion(version); err != nil {
return err
}
packageMetadata, dlerr := downloadSource(version, "")
if dlerr != nil {
return dlerr
}
_, err := buildFromSource(packageMetadata, verbose)
if err != nil {
return err
}
return nil
}
// Fetches the Python tarball for version <version> from python.org
// and stores it at <destination>.
func downloadSource(version string, destination string) (PackageMetadata, error) {
archiveName := fmt.Sprintf("Python-%s.tgz", version)
archivePath := stateManager.GetPathFromStateDirectory(path.Join("cache", archiveName))
sourceUrl := fmt.Sprintf("%s/%s/%s", pythonReleasesBaseURL, version, archiveName)
file, _ := os.Create(archivePath)
client := http.Client{}
dlPrint := util.StartFmtGroup(fmt.Sprintf("Downloading source for Python %s", version))
dlPrint(fmt.Sprintf("Fetching from %s", sourceUrl))
start := time.Now()
resp, err := client.Get(sourceUrl)
if err != nil {
return PackageMetadata{}, err
}
defer resp.Body.Close()
io.Copy(file, resp.Body)
defer file.Close()
dlPrint(fmt.Sprintf("✅ Done (%s)", time.Since(start)))
return PackageMetadata{ArchivePath: archivePath, Version: version}, nil
}
func buildFromSource(pkgMeta PackageMetadata, verbose bool) (PackageMetadata, error) {
buildPrint := util.StartFmtGroup(fmt.Sprintf("Building from source"))
start := time.Now()
buildPrint(fmt.Sprintf("Unpacking source for %s", pkgMeta.ArchivePath))
_, untarErr := RunCommand([]string{"tar", "zxvf", pkgMeta.ArchivePath}, stateManager.GetPathFromStateDirectory("cache"), !verbose)
if untarErr != nil {
return pkgMeta, untarErr
}
unzippedRoot := strings.TrimSuffix(pkgMeta.ArchivePath, path.Ext(pkgMeta.ArchivePath))
buildPrint("Configuring installer")
targetDirectory := stateManager.GetPathFromStateDirectory(path.Join("runtimes", fmt.Sprintf("py-%s", pkgMeta.Version)))
_, configureErr := RunCommand([]string{"./configure", fmt.Sprintf("--prefix=%s", targetDirectory), "--enable-optimizations"}, unzippedRoot, !verbose)
if configureErr != nil {
return pkgMeta, configureErr
}
buildPrint("Building")
_, buildErr := RunCommand([]string{"make", "altinstall"}, unzippedRoot, !verbose)
if buildErr != nil {
return pkgMeta, buildErr
}
if cleanupErr := os.RemoveAll(unzippedRoot); cleanupErr != nil {
return pkgMeta, cleanupErr
}
pkgMeta.InstallPath = targetDirectory
buildPrint(fmt.Sprintf("Installed Python %s at %s", pkgMeta.Version, pkgMeta.InstallPath))
buildPrint(fmt.Sprintf("✅ Done (%s)", time.Since(start)))
return pkgMeta, nil
}

View file

@ -0,0 +1,29 @@
package subcommands
import (
"fmt"
"os"
"strings"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
func ListVersions(args []string, flags argparse.Flags, currentState stateManager.State) error {
runtimesDir := stateManager.GetPathFromStateDirectory("runtimes")
entries, err := os.ReadDir(runtimesDir)
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Println("No versions installed!")
return nil
}
for _, d := range entries {
fmt.Println(strings.TrimPrefix(d.Name(), "py-"))
}
return nil
}

View file

@ -0,0 +1,15 @@
package subcommands
import (
"fmt"
"os"
"path"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
func UninstallPython(args []string, flags argparse.Flags, currentState stateManager.State) error {
runtimePath := stateManager.GetPathFromStateDirectory(path.Join("runtimes", fmt.Sprintf("py-%s", args[1])))
err := os.RemoveAll(runtimePath)
return err
}

View file

@ -0,0 +1,34 @@
package subcommands
import (
"errors"
"fmt"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
func Use(args []string, flags argparse.Flags, currentState stateManager.State) error {
version := args[1]
if err := validateVersion(version); err != nil {
return err
}
availableVersions := stateManager.GetAvailableVersions()
found := false
for _, v := range availableVersions {
if v == version {
found = true
break
}
}
if !found {
return errors.New("Version not installed.")
}
stateManager.WriteState(version)
fmt.Printf("Now using Python %s\n", version)
return nil
}

View file

@ -0,0 +1,33 @@
package subcommands
import (
"errors"
"os"
"os/exec"
"strings"
)
func validateVersion(version string) error {
if splitVersion := strings.Split(version, "."); len(splitVersion) != 3 {
return errors.New("Invalid version string. Expected format 'a.b.c'.")
}
return nil
}
func RunCommand(command []string, cwd string, quiet bool) (string, error) {
cmd := exec.Command(command[0], command[1:]...)
cmd.Dir = cwd
if !quiet {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err != nil {
return "", err
}
return "", nil
}

View file

@ -0,0 +1,22 @@
package subcommands
import (
"fmt"
"strings"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
func versionStringToStruct(version string) VersionTag {
splitVersion := strings.Split(version, ".")
return VersionTag{Major: splitVersion[0], Minor: splitVersion[1], Patch: splitVersion[2]}
}
func Where(args []string, flags argparse.Flags, currentState stateManager.State) error {
version := currentState.GlobalVersion
tag := versionStringToStruct(version)
withoutPatch := fmt.Sprintf("%s.%s", tag.Major, tag.Minor)
fmt.Printf("%s/runtimes/py-%s/bin/python%s\n", stateManager.GetStateDirectory(), currentState.GlobalVersion, withoutPatch)
return nil
}

View file

@ -0,0 +1,12 @@
package subcommands
import (
"fmt"
argparse "v/internal/argparse"
stateManager "v/internal/state"
)
func Which(args []string, flags argparse.Flags, currentState stateManager.State) error {
fmt.Println(currentState.GlobalVersion)
return nil
}

13
internal/util/fmtgroup.go Normal file
View file

@ -0,0 +1,13 @@
package util
import (
"fmt"
)
func StartFmtGroup(label string) func(string) {
fmt.Printf("\033[1m%s\033[0m\n", label)
return func(message string) {
fmt.Printf(" %s\n", message)
}
}

3
scripts/build Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/bash
go build cmd/v.go

3
scripts/format Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/bash
go fmt ./...