|
| 1 | +package pyproject |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "fmt" |
| 6 | + "path" |
| 7 | + "path/filepath" |
| 8 | + "sort" |
| 9 | + "text/template" |
| 10 | + |
| 11 | + "github.com/BurntSushi/toml" |
| 12 | + "github.com/sirupsen/logrus" |
| 13 | + "github.com/updatecli/updatecli/pkg/plugins/utils/version" |
| 14 | +) |
| 15 | + |
| 16 | +// pyprojectTOML mirrors the subset of pyproject.toml we care about. |
| 17 | +type pyprojectTOML struct { |
| 18 | + Project struct { |
| 19 | + Name string `toml:"name"` |
| 20 | + Dependencies []string `toml:"dependencies"` |
| 21 | + OptionalDependencies map[string][]string `toml:"optional-dependencies"` |
| 22 | + } `toml:"project"` |
| 23 | +} |
| 24 | + |
| 25 | +// loadPyprojectData reads and unmarshals a pyproject.toml file. |
| 26 | +func loadPyprojectData(filePath string) (pyprojectTOML, error) { |
| 27 | + var data pyprojectTOML |
| 28 | + if _, err := toml.DecodeFile(filePath, &data); err != nil { |
| 29 | + return data, fmt.Errorf("parsing %q: %w", filePath, err) |
| 30 | + } |
| 31 | + return data, nil |
| 32 | +} |
| 33 | + |
| 34 | +// discoverDependencyManifests is the main entry point called by DiscoverManifests. |
| 35 | +func (p Pyproject) discoverDependencyManifests() ([][]byte, error) { |
| 36 | + var manifests [][]byte |
| 37 | + |
| 38 | + searchFromDir := p.rootDir |
| 39 | + // spec.RootDir relative paths are joined onto rootDir; absolute ones were resolved in New(). |
| 40 | + if p.spec.RootDir != "" && !path.IsAbs(p.spec.RootDir) { |
| 41 | + searchFromDir = filepath.Join(p.rootDir, p.spec.RootDir) |
| 42 | + } |
| 43 | + |
| 44 | + foundFiles, err := findPyprojectFiles(searchFromDir) |
| 45 | + if err != nil { |
| 46 | + return nil, err |
| 47 | + } |
| 48 | + |
| 49 | + for _, foundFile := range foundFiles { |
| 50 | + logrus.Debugf("parsing file %q", foundFile) |
| 51 | + |
| 52 | + dir := filepath.Dir(foundFile) |
| 53 | + |
| 54 | + lockSupport, skip := detectLockFileSupport(dir, p.uvAvailable) |
| 55 | + if skip { |
| 56 | + continue |
| 57 | + } |
| 58 | + |
| 59 | + data, err := loadPyprojectData(foundFile) |
| 60 | + if err != nil { |
| 61 | + logrus.Debugln(err) |
| 62 | + continue |
| 63 | + } |
| 64 | + |
| 65 | + relativeFile, err := filepath.Rel(p.rootDir, foundFile) |
| 66 | + if err != nil { |
| 67 | + logrus.Debugln(err) |
| 68 | + continue |
| 69 | + } |
| 70 | + |
| 71 | + workdir, err := filepath.Rel(p.rootDir, dir) |
| 72 | + if err != nil { |
| 73 | + logrus.Debugln(err) |
| 74 | + continue |
| 75 | + } |
| 76 | + |
| 77 | + projectName := data.Project.Name |
| 78 | + |
| 79 | + // Process [project.dependencies] — the main dependency group. |
| 80 | + mainDeps := make([]string, len(data.Project.Dependencies)) |
| 81 | + copy(mainDeps, data.Project.Dependencies) |
| 82 | + sort.Strings(mainDeps) |
| 83 | + |
| 84 | + manifests = append(manifests, p.processDependencies(mainDeps, "", relativeFile, lockSupport, projectName, workdir)...) |
| 85 | + |
| 86 | + // Process each [project.optional-dependencies] group in deterministic order. |
| 87 | + groups := make([]string, 0, len(data.Project.OptionalDependencies)) |
| 88 | + for g := range data.Project.OptionalDependencies { |
| 89 | + groups = append(groups, g) |
| 90 | + } |
| 91 | + sort.Strings(groups) |
| 92 | + |
| 93 | + for _, group := range groups { |
| 94 | + groupDeps := make([]string, len(data.Project.OptionalDependencies[group])) |
| 95 | + copy(groupDeps, data.Project.OptionalDependencies[group]) |
| 96 | + sort.Strings(groupDeps) |
| 97 | + |
| 98 | + manifests = append(manifests, p.processDependencies(groupDeps, group, relativeFile, lockSupport, projectName, workdir)...) |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + logrus.Printf("%v manifests identified", len(manifests)) |
| 103 | + |
| 104 | + return manifests, nil |
| 105 | +} |
| 106 | + |
| 107 | +// processDependencies generates manifests for a list of PEP 508 dependency strings. |
| 108 | +// group is the optional-dependency group name, or "" for main dependencies. |
| 109 | +func (p Pyproject) processDependencies( |
| 110 | + deps []string, |
| 111 | + group string, |
| 112 | + relativeFile string, |
| 113 | + lockSupport lockFileSupport, |
| 114 | + projectName string, |
| 115 | + workdir string, |
| 116 | +) [][]byte { |
| 117 | + var manifests [][]byte |
| 118 | + |
| 119 | + tmpl, err := template.New("manifest").Parse(manifestTemplate) |
| 120 | + if err != nil { |
| 121 | + logrus.Errorln(err) |
| 122 | + return manifests |
| 123 | + } |
| 124 | + |
| 125 | + for _, depStr := range deps { |
| 126 | + dep, err := parsePEP508(depStr) |
| 127 | + if err != nil { |
| 128 | + logrus.Warningf("skipping dependency %q from %q: %s", depStr, relativeFile, err) |
| 129 | + continue |
| 130 | + } |
| 131 | + |
| 132 | + if len(p.spec.Ignore) > 0 && p.spec.Ignore.isMatchingRules(p.rootDir, relativeFile, dep.Name, dep.Version) { |
| 133 | + logrus.Debugf("ignoring %q from %q as matching ignore rule(s)", dep.Name, relativeFile) |
| 134 | + continue |
| 135 | + } |
| 136 | + |
| 137 | + if len(p.spec.Only) > 0 && !p.spec.Only.isMatchingRules(p.rootDir, relativeFile, dep.Name, dep.Version) { |
| 138 | + logrus.Debugf("ignoring %q from %q as not matching only rule(s)", dep.Name, relativeFile) |
| 139 | + continue |
| 140 | + } |
| 141 | + |
| 142 | + params := p.buildTemplateParams(dep, group, relativeFile, lockSupport, projectName, workdir) |
| 143 | + |
| 144 | + var buf bytes.Buffer |
| 145 | + if err := tmpl.Execute(&buf, params); err != nil { |
| 146 | + logrus.Debugln(err) |
| 147 | + continue |
| 148 | + } |
| 149 | + |
| 150 | + manifests = append(manifests, buf.Bytes()) |
| 151 | + } |
| 152 | + |
| 153 | + return manifests |
| 154 | +} |
| 155 | + |
| 156 | +// buildTemplateParams constructs the manifestTemplateParams for a single dependency. |
| 157 | +func (p Pyproject) buildTemplateParams( |
| 158 | + dep pythonDependency, |
| 159 | + group string, |
| 160 | + relativeFile string, |
| 161 | + lockSupport lockFileSupport, |
| 162 | + projectName string, |
| 163 | + workdir string, |
| 164 | +) manifestTemplateParams { |
| 165 | + // Build a human-readable manifest name that includes the optional group when set. |
| 166 | + var manifestName string |
| 167 | + if group == "" { |
| 168 | + manifestName = fmt.Sprintf("deps(pypi): bump %q for %q project", dep.Name, projectName) |
| 169 | + } else { |
| 170 | + manifestName = fmt.Sprintf("deps(pypi): bump %q [%s] for %q project", dep.Name, group, projectName) |
| 171 | + } |
| 172 | + |
| 173 | + // Determine version filter. |
| 174 | + // |
| 175 | + // Priority: |
| 176 | + // 1. User-specified VersionFilter from spec. |
| 177 | + // 2. Constraint derived from the dependency itself (e.g. ">=2.28"). |
| 178 | + // 3. Wildcard "*" when no version information is present. |
| 179 | + sourceVersionFilterKind := p.versionFilter.Kind |
| 180 | + sourceVersionFilterPattern := p.versionFilter.Pattern |
| 181 | + sourceVersionFilterRegex := p.versionFilter.Regex |
| 182 | + |
| 183 | + if !p.spec.VersionFilter.IsZero() && dep.Version != "" { |
| 184 | + var err error |
| 185 | + sourceVersionFilterPattern, err = p.versionFilter.GreaterThanPattern(dep.Version) |
| 186 | + if err != nil { |
| 187 | + logrus.Debugf("building version filter pattern for %q: %s", dep.Name, err) |
| 188 | + sourceVersionFilterPattern = p.versionFilter.Pattern |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + if p.spec.VersionFilter.IsZero() { |
| 193 | + if dep.Constraint != "" { |
| 194 | + sourceVersionFilterKind = version.PEP440VERSIONKIND |
| 195 | + sourceVersionFilterPattern = dep.Constraint |
| 196 | + } else { |
| 197 | + sourceVersionFilterKind = version.PEP440VERSIONKIND |
| 198 | + sourceVersionFilterPattern = "*" |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + // uv add flag for optional dependency groups. |
| 203 | + // Trailing space is intentional — the template concatenates this directly before the quoted package spec. |
| 204 | + var uvAddGroupFlag string |
| 205 | + if group != "" { |
| 206 | + uvAddGroupFlag = "--optional " + group + " " |
| 207 | + } |
| 208 | + |
| 209 | + relLockFile := filepath.Join(workdir, "uv.lock") |
| 210 | + |
| 211 | + return manifestTemplateParams{ |
| 212 | + ManifestName: manifestName, |
| 213 | + ActionID: p.actionID, |
| 214 | + SourceID: dep.Name, |
| 215 | + SourceName: fmt.Sprintf("Get latest %q package version", dep.Name), |
| 216 | + SourceVersionFilterKind: sourceVersionFilterKind, |
| 217 | + SourceVersionFilterPattern: sourceVersionFilterPattern, |
| 218 | + SourceVersionFilterRegex: sourceVersionFilterRegex, |
| 219 | + DependencyName: dep.Name, |
| 220 | + IndexURL: p.spec.IndexURL, |
| 221 | + TargetID: dep.Name, |
| 222 | + TargetName: fmt.Sprintf("deps(pypi): bump %q to {{ source %q }}", dep.Name, dep.Name), |
| 223 | + ScmID: p.scmID, |
| 224 | + UvEnabled: lockSupport.uv, |
| 225 | + UvAddGroupFlag: uvAddGroupFlag, |
| 226 | + PyprojectFile: relativeFile, |
| 227 | + LockFile: relLockFile, |
| 228 | + Workdir: workdir, |
| 229 | + } |
| 230 | +} |
0 commit comments