diff --git a/internal/check/deps.go b/internal/check/deps.go index ba78e4f..7360ab3 100644 --- a/internal/check/deps.go +++ b/internal/check/deps.go @@ -1,10 +1,13 @@ package check import ( + "bufio" "context" "fmt" + "os" "os/exec" "path/filepath" + "strings" ) // goModDownload is a variable so tests can stub it. @@ -14,10 +17,22 @@ var goModDownload = func(dir string) error { return cmd.Run() } +// pipFreezeRunner is a variable so tests can stub it. +var pipFreezeRunner = func(pipBin string) ([]byte, error) { + return exec.Command(pipBin, "freeze").Output() +} + +// pipCheckRunner is a variable so tests can stub it. +var pipCheckRunner = func(pipBin string) error { + return exec.Command(pipBin, "check").Run() +} + type DepsCheck struct { - Dir string - Stack string // "node", "python", or "go" - goCheck func(dir string) error + Dir string + Stack string // "node", "python", or "go" + goCheck func(dir string) error + pipFreeze func(pipBin string) ([]byte, error) + pipCheck func(pipBin string) error } func (c *DepsCheck) Name() string { @@ -59,7 +74,6 @@ func (c *DepsCheck) runNode() Result { Message: "node_modules directory exists", } } - return Result{ Name: c.Name(), Status: StatusFail, @@ -69,10 +83,19 @@ func (c *DepsCheck) runNode() Result { } func (c *DepsCheck) runPython() Result { - venv := filepath.Join(c.Dir, "venv") - dotVenv := filepath.Join(c.Dir, ".venv") + venvDir := c.findVenv() + if venvDir == "" { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: "Python virtual environment directory not found", + Fix: "create a virtual environment (e.g. `python -m venv .venv`) and install dependencies with `pip install -r requirements.txt` or equivalent", + } + } - if dirExists(venv) || dirExists(dotVenv) { + // No requirements.txt — venv existing is sufficient. + reqFile := filepath.Join(c.Dir, "requirements.txt") + if _, err := os.Stat(reqFile); os.IsNotExist(err) { return Result{ Name: c.Name(), Status: StatusPass, @@ -80,14 +103,146 @@ func (c *DepsCheck) runPython() Result { } } + pipBin := c.findPipBin(venvDir) + + // Run pip check for dependency conflicts first. + checkFn := c.pipCheck + if checkFn == nil { + checkFn = pipCheckRunner + } + if err := checkFn(pipBin); err != nil { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("pip check reported dependency conflicts: %v", err), + Fix: "run `pip install -r requirements.txt` inside your virtual environment to resolve conflicting or missing packages", + } + } + + // Compare pip freeze against requirements.txt to catch missing packages. + freezeFn := c.pipFreeze + if freezeFn == nil { + freezeFn = pipFreezeRunner + } + missing, err := findMissingRequirements(pipBin, reqFile, freezeFn) + if err != nil { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("could not compare installed packages to requirements.txt: %v", err), + Fix: "ensure pip is available in your virtual environment and requirements.txt is readable", + } + } + if len(missing) > 0 { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("packages listed in requirements.txt but not installed: %s", strings.Join(missing, ", ")), + Fix: "run `pip install -r requirements.txt` inside your virtual environment", + } + } + return Result{ Name: c.Name(), - Status: StatusFail, - Message: "Python virtual environment directory not found", - Fix: "create a virtual environment (e.g. `python -m venv .venv`) and install dependencies with `pip install -r requirements.txt` or equivalent", + Status: StatusPass, + Message: "Python virtual environment exists and packages match requirements.txt", } } +// findVenv returns the path of the first venv directory found, or "". +func (c *DepsCheck) findVenv() string { + for _, name := range []string{"venv", ".venv"} { + p := filepath.Join(c.Dir, name) + if dirExists(p) { + return p + } + } + return "" +} + +// findPipBin returns the pip binary path inside venvDir (Unix or Windows layout). +func (c *DepsCheck) findPipBin(venvDir string) string { + unix := filepath.Join(venvDir, "bin", "pip") + if _, err := os.Stat(unix); err == nil { + return unix + } + return filepath.Join(venvDir, "Scripts", "pip.exe") +} + +// findMissingRequirements returns package names listed in requirements.txt +// that are absent from `pip freeze` output. +func findMissingRequirements(pipBin, reqFile string, freezeFn func(string) ([]byte, error)) ([]string, error) { + required, err := parseRequirements(reqFile) + if err != nil { + return nil, fmt.Errorf("reading requirements.txt: %w", err) + } + out, err := freezeFn(pipBin) + if err != nil { + return nil, fmt.Errorf("running pip freeze: %w", err) + } + installed := parseFreeze(out) + + var missing []string + for pkg := range required { + if _, ok := installed[pkg]; !ok { + missing = append(missing, pkg) + } + } + return missing, nil +} + +// parseRequirements reads requirements.txt and returns a set of lowercase +// package names, stripping version specifiers, extras, markers, and comments. +func parseRequirements(path string) (map[string]struct{}, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + pkgs := make(map[string]struct{}) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip blanks, comments, and pip options (e.g. -r, --index-url). + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + // Strip inline comments. + if idx := strings.IndexByte(line, '#'); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + // Extract bare package name before any version specifier, extra, or marker. + name := strings.FieldsFunc(line, func(r rune) bool { + return r == '=' || r == '!' || r == '<' || r == '>' || r == '[' || r == ';' || r == ' ' + })[0] + pkgs[strings.ToLower(name)] = struct{}{} + } + return pkgs, scanner.Err() +} + +// parseFreeze parses `pip freeze` output into a set of lowercase package names. +func parseFreeze(output []byte) map[string]struct{} { + installed := make(map[string]struct{}) + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Editable installs: "-e git+...#egg=pkgname" + if strings.HasPrefix(line, "-e ") { + if idx := strings.Index(line, "#egg="); idx >= 0 { + installed[strings.ToLower(strings.TrimSpace(line[idx+5:]))] = struct{}{} + } + continue + } + parts := strings.SplitN(line, "==", 2) + installed[strings.ToLower(strings.TrimSpace(parts[0]))] = struct{}{} + } + return installed +} + func (c *DepsCheck) runGo() Result { vendorDir := filepath.Join(c.Dir, "vendor") if dirExists(vendorDir) { @@ -97,12 +252,10 @@ func (c *DepsCheck) runGo() Result { Message: "vendor directory exists; Go dependencies are vendored", } } - check := c.goCheck if check == nil { check = goModDownload } - if err := check(c.Dir); err != nil { return Result{ Name: c.Name(), @@ -111,11 +264,9 @@ func (c *DepsCheck) runGo() Result { Fix: "run `go mod download` to download Go module dependencies", } } - return Result{ Name: c.Name(), Status: StatusPass, Message: "Go module cache is populated", } -} - +} \ No newline at end of file diff --git a/internal/check/deps_test.go b/internal/check/deps_test.go index 35eee30..2d000a6 100644 --- a/internal/check/deps_test.go +++ b/internal/check/deps_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "strings" ) func TestDepsCheck_Node_PassAndFail(t *testing.T) { @@ -97,3 +98,115 @@ func TestDepsCheck_Go_PassAndFail(t *testing.T) { } } +func TestDepsCheck_Python_VenvNoRequirements_StillPass(t *testing.T) { + // Existing behaviour must be preserved: venv present, no requirements.txt → pass. + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".venv"), 0o755); err != nil { + t.Fatalf("mkdir .venv: %v", err) + } + c := &DepsCheck{Dir: dir, Stack: "python"} + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected Pass when no requirements.txt, got %v: %s", r.Status, r.Message) + } +} + +func TestDepsCheck_Python_AllPackagesPresent(t *testing.T) { + dir, pipBin := setupPythonDir(t, "requests==2.31.0\nflask>=2.0\n# comment\n") + c := &DepsCheck{ + Dir: dir, + Stack: "python", + pipCheck: func(_ string) error { return nil }, + pipFreeze: func(_ string) ([]byte, error) { + return []byte("requests==2.31.0\nFlask==2.3.0\n"), nil + }, + } + _ = pipBin + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected Pass when all packages present, got %v: %s", r.Status, r.Message) + } +} + +func TestDepsCheck_Python_MissingPackage(t *testing.T) { + dir, _ := setupPythonDir(t, "requests==2.31.0\ncelery>=5.0\n") + c := &DepsCheck{ + Dir: dir, + Stack: "python", + pipCheck: func(_ string) error { return nil }, + pipFreeze: func(_ string) ([]byte, error) { + return []byte("requests==2.31.0\n"), nil // celery absent + }, + } + r := c.Run(context.Background()) + if r.Status != StatusFail { + t.Fatalf("expected Fail for missing package, got %v: %s", r.Status, r.Message) + } + if !strings.Contains(r.Message, "celery") { + t.Errorf("expected 'celery' in message, got: %s", r.Message) + } +} + +func TestDepsCheck_Python_PipCheckConflict(t *testing.T) { + dir, _ := setupPythonDir(t, "requests\n") + c := &DepsCheck{ + Dir: dir, + Stack: "python", + pipCheck: func(_ string) error { return errors.New("conflict") }, + } + r := c.Run(context.Background()) + if r.Status != StatusFail { + t.Errorf("expected Fail when pip check reports conflict, got %v: %s", r.Status, r.Message) + } +} + +func TestDepsCheck_Python_CaseInsensitiveMatch(t *testing.T) { + // requirements.txt uses "Requests"; freeze returns "requests" — should still pass. + dir, _ := setupPythonDir(t, "Requests>=2.0\n") + c := &DepsCheck{ + Dir: dir, + Stack: "python", + pipCheck: func(_ string) error { return nil }, + pipFreeze: func(_ string) ([]byte, error) { + return []byte("requests==2.31.0\n"), nil + }, + } + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected Pass for case-insensitive match, got %v: %s", r.Status, r.Message) + } +} + +func TestDepsCheck_Python_EditableInstall(t *testing.T) { + dir, _ := setupPythonDir(t, "mylib\n") + c := &DepsCheck{ + Dir: dir, + Stack: "python", + pipCheck: func(_ string) error { return nil }, + pipFreeze: func(_ string) ([]byte, error) { + return []byte("-e git+https://github.com/org/mylib.git@main#egg=mylib\n"), nil + }, + } + r := c.Run(context.Background()) + if r.Status != StatusPass { + t.Errorf("expected Pass for editable install, got %v: %s", r.Status, r.Message) + } +} + +// setupPythonDir creates a temp dir with a .venv/bin/pip stub and a +// requirements.txt containing the given content, then returns both. +func setupPythonDir(t *testing.T, requirements string) (dir string, pipBin string) { + t.Helper() + dir = t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".venv", "bin"), 0o755); err != nil { + t.Fatalf("mkdir .venv/bin: %v", err) + } + pipBin = filepath.Join(dir, ".venv", "bin", "pip") + if err := os.WriteFile(pipBin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write pip stub: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(requirements), 0o644); err != nil { + t.Fatalf("write requirements.txt: %v", err) + } + return dir, pipBin +} \ No newline at end of file