internal/deps/python.go
100 lines · 2891 bytes
3 symbols in this file
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}