Skip to content

Commit ab9569c

Browse files
authored
feat: add PyPI resource and pyproject autodiscovery plugins (#8155)
* feat: add PyPI resource and pyproject autodiscovery plugins Add Python ecosystem support to updatecli with two new plugins: - pypi resource: queries PyPI JSON API for package versions, with PEP 440 to semver normalization (a/b/rc pre-releases), private registry support (Bearer token), and yanked version filtering. - pyproject autodiscovery: discovers pyproject.toml + uv.lock pairs, parses PEP 508 dependencies, generates manifests using pypi source and uv add shell target. Named pyproject (alias python/uv) for multi-PM extensibility following the npm pattern. Signed-off-by: Loïs Postula <lois@postu.la> * fix: cursor comments --------- Signed-off-by: Loïs Postula <lois@postu.la>
1 parent 5a40696 commit ab9569c

File tree

33 files changed

+2889
-0
lines changed

33 files changed

+2889
-0
lines changed

.github/workflows/go.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ jobs:
5555
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
5656
with:
5757
node-version: "25.8.0"
58+
- name: Install uv
59+
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
60+
with:
61+
version: "0.11.1"
5862
- name: Install GoReleaser
5963
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
6064
with:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: "Pyproject autodiscovery with version filter using git scm"
2+
scms:
3+
default:
4+
kind: git
5+
spec:
6+
url: https://github.com/astral-sh/uv-fastapi-example.git
7+
branch: "main"
8+
9+
autodiscovery:
10+
scmid: default
11+
crawlers:
12+
pyproject:
13+
versionfilter:
14+
kind: semver
15+
pattern: minoronly
16+
only:
17+
- packages:
18+
"fastapi": ""
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: "Pyproject autodiscovery using git scm"
2+
scms:
3+
default:
4+
kind: git
5+
spec:
6+
url: https://github.com/astral-sh/uv-fastapi-example.git
7+
branch: "main"
8+
9+
autodiscovery:
10+
scmid: default
11+
crawlers:
12+
pyproject:
13+
only:
14+
- packages:
15+
"fastapi": ""

pkg/core/pipeline/autodiscovery/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/npm"
3232
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/plugin"
3333
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/precommit"
34+
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/pyproject"
3435
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/terraform"
3536
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/terragrunt"
3637
"github.com/updatecli/updatecli/pkg/plugins/autodiscovery/updatecli"
@@ -234,6 +235,13 @@ var crawlerMap = map[string]struct {
234235
},
235236
spec: precommit.Spec{},
236237
},
238+
"pyproject": {
239+
newFunc: func(spec any, rootDir string, scmID string, actionID, pluginName string) (Crawler, error) {
240+
return pyproject.New(spec, rootDir, scmID, actionID)
241+
},
242+
spec: pyproject.Spec{},
243+
alias: []string{"python/uv"},
244+
},
237245
"prow": {
238246
newFunc: func(spec any, rootDir string, scmID string, actionID, pluginName string) (Crawler, error) {
239247
return kubernetes.New(spec, rootDir, scmID, actionID, kubernetes.FlavorProw)

pkg/core/pipeline/resource/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"github.com/updatecli/updatecli/pkg/plugins/resources/json"
3636
"github.com/updatecli/updatecli/pkg/plugins/resources/maven"
3737
"github.com/updatecli/updatecli/pkg/plugins/resources/npm"
38+
"github.com/updatecli/updatecli/pkg/plugins/resources/pypi"
3839
"github.com/updatecli/updatecli/pkg/plugins/resources/shell"
3940
stashBranch "github.com/updatecli/updatecli/pkg/plugins/resources/stash/branch"
4041
stashTag "github.com/updatecli/updatecli/pkg/plugins/resources/stash/tag"
@@ -208,6 +209,10 @@ func New(rs ResourceConfig) (resource Resource, err error) {
208209

209210
return npm.New(rs.Spec)
210211

212+
case "pypi":
213+
214+
return pypi.New(rs.Spec)
215+
211216
case "shell":
212217

213218
return shell.New(rs.Spec)
@@ -309,6 +314,7 @@ func GetResourceMapping() map[string]interface{} {
309314
"json": &json.Spec{},
310315
"maven": &maven.Spec{},
311316
"npm": &npm.Spec{},
317+
"pypi": &pypi.Spec{},
312318
"shell": &shell.Spec{},
313319
"stash/branch": &stashBranch.Spec{},
314320
"stash/tag": &stashTag.Spec{},
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

Comments
 (0)