From aff11f128bcb5a31cccfbd8ce84288673e144069 Mon Sep 17 00:00:00 2001 From: Vidya Sagar Reddy Desu <35026133+vidya381@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:40:58 -0500 Subject: [PATCH 1/3] add checks for pip packages to match requirements.txt --- internal/check/deps.go | 177 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 162 insertions(+), 15 deletions(-) diff --git a/internal/check/deps.go b/internal/check/deps.go index ba78e4f..c2bd74c 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 enough. + reqFile := filepath.Join(c.Dir, "requirements.txt") + if _, err := os.Stat(reqFile); os.IsNotExist(err) { return Result{ Name: c.Name(), Status: StatusPass, @@ -80,12 +103,140 @@ func (c *DepsCheck) runPython() Result { } } + pipBin := filepath.Join(venvDir, "bin", "pip") + if _, err := os.Stat(pipBin); os.IsNotExist(err) { + // Windows layout + pipBin = filepath.Join(venvDir, "Scripts", "pip.exe") + } + + // Run pip check for dependency conflicts. + 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 fix conflicting or missing packages", + } + } + + // Compare pip freeze against requirements.txt to find 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 "" +} + +// 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 (strips version specifiers and ignores comments/blanks). +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()) + 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. + name := strings.FieldsFunc(line, func(r rune) bool { + return r == '=' || r == '!' || r == '<' || r == '>' || r == '[' || r == ';' + })[0] + pkgs[strings.ToLower(strings.TrimSpace(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 + } + // Handle editable installs: "-e git+...#egg=pkgname" + if strings.HasPrefix(line, "-e ") { + if idx := strings.Index(line, "#egg="); idx >= 0 { + name := strings.TrimSpace(line[idx+5:]) + installed[strings.ToLower(name)] = struct{}{} + } + continue + } + parts := strings.SplitN(line, "==", 2) + installed[strings.ToLower(strings.TrimSpace(parts[0]))] = struct{}{} + } + return installed } func (c *DepsCheck) runGo() Result { @@ -97,12 +248,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 +260,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 From 0674b90cbb4a136a5f431a90a8e9b9cd2939da25 Mon Sep 17 00:00:00 2001 From: Vidya Sagar Reddy Desu <35026133+vidya381@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:56:36 -0500 Subject: [PATCH 2/3] fix pip package checks --- internal/check/deps.go | 48 +++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/internal/check/deps.go b/internal/check/deps.go index c2bd74c..7360ab3 100644 --- a/internal/check/deps.go +++ b/internal/check/deps.go @@ -28,11 +28,11 @@ var pipCheckRunner = func(pipBin string) error { } type DepsCheck struct { - Dir string - Stack string // "node", "python", or "go" - goCheck func(dir string) error - pipFreeze func(pipBin string) ([]byte, error) - pipCheck func(pipBin 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 { @@ -93,7 +93,7 @@ func (c *DepsCheck) runPython() Result { } } - // No requirements.txt — venv existing is enough. + // No requirements.txt — venv existing is sufficient. reqFile := filepath.Join(c.Dir, "requirements.txt") if _, err := os.Stat(reqFile); os.IsNotExist(err) { return Result{ @@ -103,13 +103,9 @@ func (c *DepsCheck) runPython() Result { } } - pipBin := filepath.Join(venvDir, "bin", "pip") - if _, err := os.Stat(pipBin); os.IsNotExist(err) { - // Windows layout - pipBin = filepath.Join(venvDir, "Scripts", "pip.exe") - } + pipBin := c.findPipBin(venvDir) - // Run pip check for dependency conflicts. + // Run pip check for dependency conflicts first. checkFn := c.pipCheck if checkFn == nil { checkFn = pipCheckRunner @@ -119,11 +115,11 @@ func (c *DepsCheck) runPython() 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 fix conflicting or missing packages", + Fix: "run `pip install -r requirements.txt` inside your virtual environment to resolve conflicting or missing packages", } } - // Compare pip freeze against requirements.txt to find missing packages. + // Compare pip freeze against requirements.txt to catch missing packages. freezeFn := c.pipFreeze if freezeFn == nil { freezeFn = pipFreezeRunner @@ -164,6 +160,15 @@ func (c *DepsCheck) findVenv() string { 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) { @@ -171,7 +176,6 @@ func findMissingRequirements(pipBin, reqFile string, freezeFn func(string) ([]by 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) @@ -188,7 +192,7 @@ func findMissingRequirements(pipBin, reqFile string, freezeFn func(string) ([]by } // parseRequirements reads requirements.txt and returns a set of lowercase -// package names (strips version specifiers and ignores comments/blanks). +// 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 { @@ -200,6 +204,7 @@ func parseRequirements(path string) (map[string]struct{}, error) { 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 } @@ -207,11 +212,11 @@ func parseRequirements(path string) (map[string]struct{}, error) { if idx := strings.IndexByte(line, '#'); idx >= 0 { line = strings.TrimSpace(line[:idx]) } - // Extract bare package name before any version specifier. + // 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 == ';' + return r == '=' || r == '!' || r == '<' || r == '>' || r == '[' || r == ';' || r == ' ' })[0] - pkgs[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + pkgs[strings.ToLower(name)] = struct{}{} } return pkgs, scanner.Err() } @@ -225,11 +230,10 @@ func parseFreeze(output []byte) map[string]struct{} { if line == "" || strings.HasPrefix(line, "#") { continue } - // Handle editable installs: "-e git+...#egg=pkgname" + // Editable installs: "-e git+...#egg=pkgname" if strings.HasPrefix(line, "-e ") { if idx := strings.Index(line, "#egg="); idx >= 0 { - name := strings.TrimSpace(line[idx+5:]) - installed[strings.ToLower(name)] = struct{}{} + installed[strings.ToLower(strings.TrimSpace(line[idx+5:]))] = struct{}{} } continue } From d9101ea9ecd9801786a28ff554bd923cc5567194 Mon Sep 17 00:00:00 2001 From: Vidya Sagar Reddy Desu <35026133+vidya381@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:49:58 -0500 Subject: [PATCH 3/3] add tests for pip package requirement checks --- internal/check/deps_test.go | 113 ++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) 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