internal/deps/python.go

6f16ad5df0845d8f44ec6530f1d1d6993d8dcb70
gitbay/internal/deps/python.go history · blame · raw

100 lines · 2891 bytes

  1package deps
  2
  3import (
  4	"strings"
  5
  6	"github.com/BurntSushi/toml"
  7)
  8
  9// parsePython reads requirements.txt and pyproject.toml. Only requirements
 10// that name one release are reported: a bare `requests` or a `>=2,<3` range
 11// says the project accepts whatever is current, so there is nothing to tell
 12// its maintainer.
 13func parsePython(read ReadFile) []Dep {
 14	seen := map[string]bool{}
 15	var out []Dep
 16	add := func(name, version string) {
 17		name = normalizePyPI(name)
 18		if name == "" || name == "python" || seen[name] {
 19			return
 20		}
 21		if v := pin(version); v != "" {
 22			seen[name] = true
 23			out = append(out, Dep{Ecosystem: EcoPyPI, Name: name, Current: v})
 24		}
 25	}
 26	if raw, err := read("requirements.txt"); err == nil {
 27		for _, line := range strings.Split(string(raw), "\n") {
 28			if i := strings.Index(line, "#"); i >= 0 {
 29				line = line[:i]
 30			}
 31			line = strings.TrimSpace(line)
 32			if line == "" || strings.HasPrefix(line, "-") {
 33				continue
 34			}
 35			name, version := splitPEP508(line)
 36			add(name, version)
 37		}
 38	}
 39	if raw, err := read("pyproject.toml"); err == nil {
 40		var doc struct {
 41			Project struct {
 42				Dependencies []string `toml:"dependencies"`
 43			} `toml:"project"`
 44			Tool struct {
 45				Poetry struct {
 46					Dependencies    map[string]any `toml:"dependencies"`
 47					DevDependencies map[string]any `toml:"dev-dependencies"`
 48				} `toml:"poetry"`
 49			} `toml:"tool"`
 50		}
 51		if toml.Unmarshal(raw, &doc) == nil {
 52			for _, spec := range doc.Project.Dependencies {
 53				name, version := splitPEP508(spec)
 54				add(name, version)
 55			}
 56			poetry := doc.Tool.Poetry
 57			for _, set := range []map[string]any{poetry.Dependencies, poetry.DevDependencies} {
 58				for name, spec := range set {
 59					// Poetry shares Cargo's two forms: a bare string, or a
 60					// table that may point somewhere other than PyPI.
 61					if req, ok := cargoRequirement(spec); ok {
 62						add(name, req)
 63					}
 64				}
 65			}
 66		}
 67	}
 68	return out
 69}
 70
 71// splitPEP508 separates the distribution name from its version specifier,
 72// dropping extras and environment markers: `django[bcrypt]==5.0 ; sys_platform
 73// != "win32"` becomes ("django", "==5.0").
 74func splitPEP508(spec string) (name, version string) {
 75	if i := strings.Index(spec, ";"); i >= 0 {
 76		spec = spec[:i]
 77	}
 78	spec = strings.TrimSpace(spec)
 79	i := strings.IndexAny(spec, "[<>=!~ ")
 80	if i < 0 {
 81		return spec, ""
 82	}
 83	name, version = spec[:i], spec[i:]
 84	if j := strings.Index(version, "]"); j >= 0 {
 85		version = version[j+1:]
 86	}
 87	return name, strings.TrimSpace(version)
 88}
 89
 90// normalizePyPI applies PEP 503 name normalization, so `Flask_SQLAlchemy`
 91// and `flask-sqlalchemy` are one dependency.
 92func normalizePyPI(name string) string {
 93	name = strings.TrimSpace(strings.ToLower(name))
 94	name = strings.ReplaceAll(name, "_", "-")
 95	name = strings.ReplaceAll(name, ".", "-")
 96	for strings.Contains(name, "--") {
 97		name = strings.ReplaceAll(name, "--", "-")
 98	}
 99	return name
100}