Skip to content

Commit 834147a

Browse files
authored
[zeroconfig] Implement --autodetect flag (#2325)
## Summary Closes DEV-151 (re) implements something similar to our old "planner" framework but only for python/poetry. It's only opt-in. Using the `--autodetect` with `init` flag generates a devbox.json with any detection results. Currently the framework only only uses the most relevant detector, but could be modified to use multiple detectors. ## How was it tested? `devbox init --autodetect --dry-run`
1 parent d8e8bbe commit 834147a

File tree

12 files changed

+271
-49
lines changed

12 files changed

+271
-49
lines changed

devbox.json

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,43 @@
11
{
2-
"name": "devbox",
2+
"name": "devbox",
33
"description": "Instant, easy, and predictable development environments",
44
"packages": {
5-
"go": "latest",
5+
"go": "latest",
66
"runx:golangci/golangci-lint": "latest",
7-
"runx:mvdan/gofumpt": "latest"
7+
"runx:mvdan/gofumpt": "latest",
88
},
99
"env": {
1010
"GOENV": "off",
11-
"PATH": "$PATH:$PWD/dist"
11+
"PATH": "$PATH:$PWD/dist",
1212
},
1313
"shell": {
1414
"init_hook": [
1515
// Remove Go environment variables that might've been inherited from the
1616
// user's environment and could affect the build.
1717
"test -z $FISH_VERSION && \\",
1818
"unset CGO_ENABLED GO111MODULE GOARCH GOFLAGS GOMOD GOOS GOROOT GOTOOLCHAIN GOWORK || \\",
19-
"set --erase CGO_ENABLED GO111MODULE GOARCH GOFLAGS GOMOD GOOS GOROOT GOTOOLCHAIN GOWORK"
19+
"set --erase CGO_ENABLED GO111MODULE GOARCH GOFLAGS GOMOD GOOS GOROOT GOTOOLCHAIN GOWORK",
2020
],
2121
"scripts": {
2222
// Build devbox for the current platform
23-
"build": "go build -o dist/devbox ./cmd/devbox",
23+
"build": "go build -o dist/devbox ./cmd/devbox",
2424
"build-darwin-amd64": "GOOS=darwin GOARCH=amd64 go build -o dist/devbox-darwin-amd64 ./cmd/devbox",
2525
"build-darwin-arm64": "GOOS=darwin GOARCH=arm64 go build -o dist/devbox-darwin-arm64 ./cmd/devbox",
26-
"build-linux-amd64": "GOOS=linux GOARCH=amd64 go build -o dist/devbox-linux-amd64 ./cmd/devbox",
27-
"build-linux-arm64": "GOOS=linux GOARCH=arm64 go build -o dist/devbox-linux-arm64 ./cmd/devbox",
26+
"build-linux-amd64": "GOOS=linux GOARCH=amd64 go build -o dist/devbox-linux-amd64 ./cmd/devbox",
27+
"build-linux-arm64": "GOOS=linux GOARCH=arm64 go build -o dist/devbox-linux-arm64 ./cmd/devbox",
2828
"build-all": [
2929
"devbox run build-darwin-amd64",
3030
"devbox run build-darwin-arm64",
3131
"devbox run build-linux-amd64",
32-
"devbox run build-linux-arm64"
32+
"devbox run build-linux-arm64",
3333
],
3434
// Open VSCode
35-
"code": "code .",
36-
"lint": "golangci-lint run --timeout 5m && scripts/gofumpt.sh",
37-
"fmt": "scripts/gofumpt.sh",
38-
"test": "go test -race -cover ./...",
35+
"code": "code .",
36+
"lint": "golangci-lint run --timeout 5m && scripts/gofumpt.sh",
37+
"fmt": "scripts/gofumpt.sh",
38+
"test": "go test -race -cover ./...",
3939
"test-projects-only": "DEVBOX_RUN_PROJECT_TESTS=1 go test -v -timeout ${DEVBOX_GOLANG_TEST_TIMEOUT:-30m} ./... -run \"TestExamples|TestScriptsWithProjects\"",
40-
"update-examples": "devbox run build && go run testscripts/testrunner/updater/main.go",
40+
"update-examples": "devbox run build && go run testscripts/testrunner/updater/main.go",
4141
// Updates the Flake's vendorHash: First run `go mod vendor` to vendor
4242
// the dependencies, then hash the vendor directory with Nix.
4343
// The hash is saved to the `vendor-hash` file, which is then
@@ -68,8 +68,8 @@
6868
"GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o testscripts-linux-amd64",
6969
"GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go test -c -o testscripts-linux-arm64",
7070
"image=$(docker build --quiet --tag devbox-testscripts-ubuntu:noble --platform linux/amd64 .)",
71-
"docker run --rm --mount type=volume,src=devbox-testscripts-amd64,dst=/nix --platform linux/amd64 -e DEVBOX_RUN_FAILING_TESTS -e DEVBOX_RUN_PROJECT_TESTS -e DEVBOX_DEBUG $image \"$@\""
72-
]
73-
}
74-
}
71+
"docker run --rm --mount type=volume,src=devbox-testscripts-amd64,dst=/nix --platform linux/amd64 -e DEVBOX_RUN_FAILING_TESTS -e DEVBOX_RUN_PROJECT_TESTS -e DEVBOX_DEBUG $image \"$@\"",
72+
],
73+
},
74+
},
7575
}

internal/autodetect/autodetect.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package autodetect
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
8+
"go.jetpack.io/devbox/internal/autodetect/detector"
9+
"go.jetpack.io/devbox/internal/devbox"
10+
"go.jetpack.io/devbox/internal/devbox/devopt"
11+
)
12+
13+
func PopulateConfig(ctx context.Context, path string, stderr io.Writer) error {
14+
pkgs, err := packages(ctx, path)
15+
if err != nil {
16+
return err
17+
}
18+
devbox, err := devbox.Open(&devopt.Opts{
19+
Dir: path,
20+
Stderr: stderr,
21+
})
22+
if err != nil {
23+
return err
24+
}
25+
return devbox.Add(ctx, pkgs, devopt.AddOpts{})
26+
}
27+
28+
func DryRun(ctx context.Context, path string, stderr io.Writer) error {
29+
pkgs, err := packages(ctx, path)
30+
if err != nil {
31+
return err
32+
} else if len(pkgs) == 0 {
33+
fmt.Fprintln(stderr, "No packages to add")
34+
return nil
35+
}
36+
fmt.Fprintln(stderr, "Packages to add:")
37+
for _, pkg := range pkgs {
38+
fmt.Fprintln(stderr, pkg)
39+
}
40+
return nil
41+
}
42+
43+
func detectors(path string) []detector.Detector {
44+
return []detector.Detector{
45+
&detector.PythonDetector{Root: path},
46+
&detector.PoetryDetector{Root: path},
47+
}
48+
}
49+
50+
func packages(ctx context.Context, path string) ([]string, error) {
51+
mostRelevantDetector, err := relevantDetector(path)
52+
if err != nil || mostRelevantDetector == nil {
53+
return nil, err
54+
}
55+
return mostRelevantDetector.Packages(ctx)
56+
}
57+
58+
// relevantDetector returns the most relevant detector for the given path.
59+
// We could modify this to return a list of detectors and their scores or
60+
// possibly grouped detectors by category (e.g. python, server, etc.)
61+
func relevantDetector(path string) (detector.Detector, error) {
62+
relevantScore := 0.0
63+
var mostRelevantDetector detector.Detector
64+
for _, detector := range detectors(path) {
65+
score, err := detector.Relevance(path)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if score > relevantScore {
70+
relevantScore = score
71+
mostRelevantDetector = detector
72+
}
73+
}
74+
return mostRelevantDetector, nil
75+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package detector
2+
3+
import "context"
4+
5+
type Detector interface {
6+
Relevance(path string) (float64, error)
7+
Packages(ctx context.Context) ([]string, error)
8+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/pelletier/go-toml/v2"
11+
"go.jetpack.io/devbox/internal/searcher"
12+
)
13+
14+
type PoetryDetector struct {
15+
PythonDetector
16+
Root string
17+
}
18+
19+
var _ Detector = &PoetryDetector{}
20+
21+
func (d *PoetryDetector) Relevance(path string) (float64, error) {
22+
pyprojectPath := filepath.Join(d.Root, "pyproject.toml")
23+
_, err := os.Stat(pyprojectPath)
24+
if err == nil {
25+
return d.maxRelevance(), nil
26+
}
27+
if os.IsNotExist(err) {
28+
return 0, nil
29+
}
30+
return 0, err
31+
}
32+
33+
func (d *PoetryDetector) Packages(ctx context.Context) ([]string, error) {
34+
pyprojectPath := filepath.Join(d.Root, "pyproject.toml")
35+
pyproject, err := os.ReadFile(pyprojectPath)
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
var pyprojectToml struct {
41+
Tool struct {
42+
Poetry struct {
43+
Version string `toml:"version"`
44+
Dependencies struct {
45+
Python string `toml:"python"`
46+
} `toml:"dependencies"`
47+
} `toml:"poetry"`
48+
} `toml:"tool"`
49+
}
50+
err = toml.Unmarshal(pyproject, &pyprojectToml)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
poetryVersion := determineBestVersion(ctx, "poetry", pyprojectToml.Tool.Poetry.Version)
56+
pythonVersion := determineBestVersion(ctx, "python", pyprojectToml.Tool.Poetry.Dependencies.Python)
57+
58+
return []string{"python@" + pythonVersion, "poetry@" + poetryVersion}, nil
59+
}
60+
61+
func determineBestVersion(ctx context.Context, pkg, version string) string {
62+
if version == "" {
63+
return "latest"
64+
}
65+
66+
version = sanitizeVersion(version)
67+
68+
_, err := searcher.Client().ResolveV2(ctx, pkg, version)
69+
if err != nil {
70+
return "latest"
71+
}
72+
73+
return version
74+
}
75+
76+
func sanitizeVersion(version string) string {
77+
// Remove non-numeric characters and 'v' prefix
78+
sanitized := strings.TrimPrefix(version, "v")
79+
return regexp.MustCompile(`[^\d.]`).ReplaceAllString(sanitized, "")
80+
}
81+
82+
func (d *PoetryDetector) maxRelevance() float64 {
83+
// this is arbitrary, but we want to prioritize poetry over python
84+
return d.PythonDetector.maxRelevance() + 1
85+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
type PythonDetector struct {
10+
Root string
11+
}
12+
13+
var _ Detector = &PythonDetector{}
14+
15+
func (d *PythonDetector) Relevance(path string) (float64, error) {
16+
requirementsPath := filepath.Join(d.Root, "requirements.txt")
17+
_, err := os.Stat(requirementsPath)
18+
if err == nil {
19+
return d.maxRelevance(), nil
20+
}
21+
if os.IsNotExist(err) {
22+
return 0, nil
23+
}
24+
return 0, err
25+
}
26+
27+
func (d *PythonDetector) Packages(ctx context.Context) ([]string, error) {
28+
return []string{"python@latest"}, nil
29+
}
30+
31+
func (d *PythonDetector) maxRelevance() float64 {
32+
return 1.0
33+
}

internal/boxcli/global.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func ensureGlobalConfig() (string, error) {
6363
if err != nil {
6464
return "", err
6565
}
66-
_, err = devbox.InitConfig(globalConfigPath)
66+
err = devbox.InitConfig(globalConfigPath)
6767
if err != nil {
6868
return "", err
6969
}

internal/boxcli/init.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import (
77
"github.com/pkg/errors"
88
"github.com/spf13/cobra"
99

10+
"go.jetpack.io/devbox/internal/autodetect"
1011
"go.jetpack.io/devbox/internal/devbox"
1112
)
1213

14+
type initFlags struct {
15+
auto bool
16+
dryRun bool
17+
}
18+
1319
func initCmd() *cobra.Command {
20+
flags := &initFlags{}
1421
command := &cobra.Command{
1522
Use: "init [<dir>]",
1623
Short: "Initialize a directory as a devbox project",
@@ -19,16 +26,33 @@ func initCmd() *cobra.Command {
1926
"You can then add packages using `devbox add`",
2027
Args: cobra.MaximumNArgs(1),
2128
RunE: func(cmd *cobra.Command, args []string) error {
22-
return runInitCmd(args)
29+
return runInitCmd(cmd, args, flags)
2330
},
2431
}
2532

33+
command.Flags().BoolVar(&flags.auto, "auto", false, "Automatically detect packages to add")
34+
command.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Dry run")
35+
_ = command.Flags().MarkHidden("auto")
36+
_ = command.Flags().MarkHidden("dry-run")
37+
2638
return command
2739
}
2840

29-
func runInitCmd(args []string) error {
41+
func runInitCmd(cmd *cobra.Command, args []string, flags *initFlags) error {
3042
path := pathArg(args)
3143

32-
_, err := devbox.InitConfig(path)
44+
if flags.auto && flags.dryRun {
45+
return autodetect.DryRun(cmd.Context(), path, cmd.ErrOrStderr())
46+
}
47+
48+
err := devbox.InitConfig(path)
49+
if err != nil {
50+
return errors.WithStack(err)
51+
}
52+
53+
if flags.auto {
54+
err = autodetect.PopulateConfig(cmd.Context(), path, cmd.ErrOrStderr())
55+
}
56+
3357
return errors.WithStack(err)
3458
}

internal/devbox/devbox.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ type Devbox struct {
7171

7272
var legacyPackagesWarningHasBeenShown = false
7373

74-
func InitConfig(dir string) (bool, error) {
74+
func InitConfig(dir string) error {
7575
return devconfig.Init(dir)
7676
}
7777

internal/devbox/devbox_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func TestComputeDevboxPathWhenRemoving(t *testing.T) {
122122

123123
func devboxForTesting(t *testing.T) *Devbox {
124124
path := t.TempDir()
125-
_, err := devconfig.Init(path)
125+
err := devconfig.Init(path)
126126
require.NoError(t, err, "InitConfig should not fail")
127127
d, err := Open(&devopt.Opts{
128128
Dir: path,

internal/devbox/util.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func ensureDevboxUtilityConfig() (string, error) {
5555
return "", err
5656
}
5757

58-
_, err = InitConfig(path)
58+
err = InitConfig(path)
5959
if err != nil {
6060
return "", err
6161
}

0 commit comments

Comments
 (0)