diff --git a/cmd/omnibump/root.go b/cmd/omnibump/root.go index a0cc61c..5f60b69 100644 --- a/cmd/omnibump/root.go +++ b/cmd/omnibump/root.go @@ -21,6 +21,7 @@ import ( _ "github.com/chainguard-dev/omnibump/pkg/languages/golang" // Register Go _ "github.com/chainguard-dev/omnibump/pkg/languages/java" // Register Java (Maven, Gradle, etc.) _ "github.com/chainguard-dev/omnibump/pkg/languages/php" // Register PHP (Composer, etc.) + _ "github.com/chainguard-dev/omnibump/pkg/languages/python" // Register Python _ "github.com/chainguard-dev/omnibump/pkg/languages/rust" // Register Rust charmlog "github.com/charmbracelet/log" "github.com/spf13/cobra" @@ -40,6 +41,8 @@ type rootFlags struct { dryRun bool logLevel string logPolicy []string + tool string + venv string } var flags rootFlags @@ -75,13 +78,15 @@ func New() *cobra.Command { // Add root command flags f := cmd.Flags() - f.StringVarP(&flags.language, "language", "l", "auto", "language to use (auto, java, go, rust, or deprecated: maven)") + f.StringVarP(&flags.language, "language", "l", "auto", "language to use (auto, java, go, python, rust, or deprecated: maven)") f.StringVar(&flags.depsFile, "deps", "", "dependencies file (deps.yaml, or legacy names)") f.StringVar(&flags.propertiesFile, "properties", "", "properties file (properties.yaml)") f.StringVar(&flags.packages, "packages", "", "inline package list (space-separated)") f.StringVar(&flags.replaces, "replaces", "", "inline replace list (space-separated, format: oldpkg=newpkg@version)") f.StringVar(&flags.properties, "props", "", "inline properties list (space-separated)") f.StringVar(&flags.rootDir, "dir", ".", "project root directory") + f.StringVar(&flags.tool, "tool", "", "build tool override (Python: uv, pip, poetry, hatch, pdm, setuptools)") + f.StringVar(&flags.venv, "venv", "", "path to staged Python venv for in-place bumping (Python only)") f.BoolVar(&flags.tidy, "tidy", false, "run tidy command after update") f.BoolVar(&flags.showDiff, "show-diff", false, "show diff of changes") f.BoolVar(&flags.dryRun, "dry-run", false, "simulate update without making changes") @@ -329,6 +334,12 @@ func runUpdate(cmd *cobra.Command, _ []string) error { // args unused but requir updateCfg.Tidy = flags.tidy updateCfg.ShowDiff = flags.showDiff updateCfg.DryRun = flags.dryRun + if flags.tool != "" { + updateCfg.Options["tool"] = flags.tool + } + if flags.venv != "" { + updateCfg.Options["venv"] = flags.venv + } // Perform update if err := lang.Update(ctx, updateCfg); err != nil { diff --git a/omnibump b/omnibump new file mode 100755 index 0000000..3fe415b Binary files /dev/null and b/omnibump differ diff --git a/pkg/languages/python/analyzer.go b/pkg/languages/python/analyzer.go new file mode 100644 index 0000000..f1de627 --- /dev/null +++ b/pkg/languages/python/analyzer.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/chainguard-dev/clog" + "github.com/chainguard-dev/omnibump/pkg/analyzer" +) + +// Analyzer implements analyzer.Analyzer for Python projects. +type Analyzer struct{} + +// Verify Analyzer implements the analyzer.Analyzer interface at compile time. +var _ analyzer.Analyzer = (*Analyzer)(nil) + +// Analyze parses all manifest files in projectPath and returns a dependency map. +func (a *Analyzer) Analyze(ctx context.Context, projectPath string) (*analyzer.AnalysisResult, error) { + _ = ctx + + absPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("resolving path: %w", err) + } + + manifest, err := DetectManifest(absPath) + if err != nil { + return nil, fmt.Errorf("detecting manifest in %s: %w", absPath, err) + } + + result := &analyzer.AnalysisResult{ + Language: "python", + Dependencies: make(map[string]*analyzer.DependencyInfo), + Properties: make(map[string]string), + PropertyUsage: make(map[string]int), + Metadata: map[string]any{"buildTool": string(manifest.BuildTool), "manifest": manifest.Type}, + } + + specs, err := readSpecsFromManifest(manifest) + if err != nil { + return nil, err + } + + for _, spec := range specs { + result.Dependencies[spec.Package] = &analyzer.DependencyInfo{ + Name: spec.Package, + Version: spec.Version, + UpdateStrategy: "direct", + Metadata: map[string]any{ + "specifier": spec.Specifier, + "rawLine": spec.RawLine, + }, + } + } + + return result, nil +} + +// AnalyzeRemote analyzes manifest files provided as raw bytes. +// files is a map of filename to content (e.g. "pyproject.toml" -> bytes). +func (a *Analyzer) AnalyzeRemote(ctx context.Context, files map[string][]byte) (*analyzer.RemoteAnalysisResult, error) { + log := clog.FromContext(ctx) + result := &analyzer.RemoteAnalysisResult{Language: "python"} + + for _, name := range manifestPriority { + data, ok := files[name] + if !ok { + continue + } + + bt := BuildToolUnknown + var specs []VersionSpec + + switch name { + case ManifestPyprojectTOML: + // Write to a temp file to reuse DetectBuildToolFromPyproject + tmp, err := os.CreateTemp("", "pyproject-*.toml") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + defer func() { + if err := os.Remove(tmpPath); err != nil { + log.Warnf("failed to remove temp file: %v", err) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return nil, err + } + _ = tmp.Close() + + bt, _ = DetectBuildToolFromPyproject(tmpPath) + specs, _ = ParsePyprojectDeps(data, bt) + case ManifestRequirementsTxt: + bt = BuildToolPip + specs = ParseRequirements(data) + case ManifestSetupCfg: + bt = BuildToolSetuptools + specs = ParseSetupCfg(data) + case ManifestSetupPy: + bt = BuildToolSetuptools + specs = ParseSetupPy(data) + case ManifestPipfile: + bt = BuildToolPip + specs, _ = ParsePipfile(data) + } + + if len(specs) == 0 { + continue + } + + ar := &analyzer.AnalysisResult{ + Language: "python", + Dependencies: make(map[string]*analyzer.DependencyInfo), + Properties: make(map[string]string), + PropertyUsage: make(map[string]int), + Metadata: map[string]any{"buildTool": string(bt), "manifest": name}, + } + for _, spec := range specs { + ar.Dependencies[spec.Package] = &analyzer.DependencyInfo{ + Name: spec.Package, + Version: spec.Version, + UpdateStrategy: "direct", + Metadata: map[string]any{ + "specifier": spec.Specifier, + "rawLine": spec.RawLine, + }, + } + } + result.FileAnalyses = append(result.FileAnalyses, analyzer.FileAnalysis{ + FilePath: name, + Analysis: ar, + }) + break // Use only the highest-priority manifest + } + + return result, nil +} + +// RecommendStrategy always recommends direct updates for Python deps. +// Python doesn't have a "property" abstraction like Maven. +func (a *Analyzer) RecommendStrategy(_ context.Context, _ *analyzer.AnalysisResult, deps []analyzer.Dependency) (*analyzer.Strategy, error) { + strategy := &analyzer.Strategy{ + DirectUpdates: make([]analyzer.Dependency, 0, len(deps)), + PropertyUpdates: make(map[string]string), + Warnings: []string{}, + AffectedDependencies: make(map[string][]string), + } + strategy.DirectUpdates = append(strategy.DirectUpdates, deps...) + return strategy, nil +} + +// readSpecsFromManifest reads dependency specs from a detected manifest. +func readSpecsFromManifest(manifest *ManifestInfo) ([]VersionSpec, error) { + data, err := os.ReadFile(manifest.Path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", manifest.Path, err) + } + + switch manifest.Type { + case ManifestPyprojectTOML: + return ParsePyprojectDeps(data, manifest.BuildTool) + case ManifestRequirementsTxt: + return ParseRequirements(data), nil + case ManifestSetupCfg: + return ParseSetupCfg(data), nil + case ManifestSetupPy: + return ParseSetupPy(data), nil + case ManifestPipfile: + specs, err := ParsePipfile(data) + if err != nil { + return nil, err + } + return specs, nil + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedManifestType, manifest.Type) + } +} diff --git a/pkg/languages/python/detector.go b/pkg/languages/python/detector.go new file mode 100644 index 0000000..fc63775 --- /dev/null +++ b/pkg/languages/python/detector.go @@ -0,0 +1,181 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// manifestPriority defines the manifest files checked in order. +// pyproject.toml covers the widest range of modern build tools and is checked first. +var manifestPriority = []string{ + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "Pipfile", +} + +// DetectManifestWithHint returns a manifest file with optional tool preference. +// If toolHint is non-empty, it reorders the priority to check the preferred +// manifest for that tool first, then falls back to the standard order. +// toolHint examples: "pip" → requirements.txt, "poetry" → pyproject.toml, etc. +func DetectManifestWithHint(dir, toolHint string) (*ManifestInfo, error) { + priority := manifestPriority + if toolHint != "" { + priority = reorderManifestPriority(toolHint, manifestPriority) + } + return detectManifestWithPriority(dir, priority) +} + +// DetectManifest returns the highest-priority manifest file found in dir. +// The returned ManifestInfo includes the build tool inferred from the file. +func DetectManifest(dir string) (*ManifestInfo, error) { + return detectManifestWithPriority(dir, manifestPriority) +} + +// detectManifestWithPriority returns the highest-priority manifest file found in dir. +// The returned ManifestInfo includes the build tool inferred from the file. +func detectManifestWithPriority(dir string, priority []string) (*ManifestInfo, error) { + for _, name := range priority { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err != nil { + continue + } + + info := &ManifestInfo{Path: path, Type: name} + info.BuildTool = detectBuildTool(name, dir, path) + return info, nil + } + return nil, ErrManifestNotFound +} + +// detectBuildTool determines the build tool for a manifest file. +func detectBuildTool(name, dir, path string) BuildTool { + if name != ManifestPyprojectTOML { + return toolForManifest(name) + } + + bt, err := DetectBuildToolFromPyproject(path) + if err != nil { + bt = BuildToolUnknown + } + // Prefer uv if a uv.lock exists alongside pyproject.toml. + if bt == BuildToolUnknown || bt == BuildToolHatch || bt == BuildToolSetuptools { + if HasUVLock(dir) { + bt = BuildToolUV + } + } + if HasPDMLock(dir) && bt == BuildToolUnknown { + bt = BuildToolPDM + } + return bt +} + +// toolForManifest returns the build tool associated with a non-pyproject manifest. +func toolForManifest(name string) BuildTool { + switch name { + case ManifestRequirementsTxt: + return BuildToolPip + case ManifestSetupCfg, ManifestSetupPy: + return BuildToolSetuptools + case ManifestPipfile: + return BuildToolPip + default: + return BuildToolUnknown + } +} + +// pyprojectBuildSystem is used only to read the [build-system] table. +type pyprojectBuildSystem struct { + BuildSystem struct { + BuildBackend string `toml:"build-backend"` + } `toml:"build-system"` +} + +// DetectBuildToolFromPyproject reads [build-system].build-backend from a pyproject.toml. +func DetectBuildToolFromPyproject(path string) (BuildTool, error) { + var doc pyprojectBuildSystem + if _, err := toml.DecodeFile(path, &doc); err != nil { + return BuildToolUnknown, err + } + + switch doc.BuildSystem.BuildBackend { + case "hatchling.build": + return BuildToolHatch, nil + case "poetry.core.masonry.api": + return BuildToolPoetry, nil + case "pdm.backend": + return BuildToolPDM, nil + case "maturin": + return BuildToolMaturin, nil + case "scikit_build_core.build": + return BuildToolScikitBuildCore, nil + case "scikit_build.build": + return BuildToolScikitBuild, nil + case "setuptools.build_meta", "setuptools.build_meta:__legacy__": + return BuildToolSetuptools, nil + case "flit_core.buildapi", "flit.buildapi": + return BuildToolHatch, nil // flit uses PEP 621 [project].dependencies + default: + if doc.BuildSystem.BuildBackend == "" { + return BuildToolUnknown, nil + } + // Unknown backend — still treat [project].dependencies as PEP 621 + return BuildToolUnknown, nil + } +} + +// HasUVLock returns true when a uv.lock file exists in dir. +func HasUVLock(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "uv.lock")) + return err == nil +} + +// HasPDMLock returns true when a pdm.lock file exists in dir. +func HasPDMLock(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "pdm.lock")) + return err == nil +} + +// reorderManifestPriority returns a reordered manifest priority list with the +// preferred manifest for the given tool hint moved to the front. +func reorderManifestPriority(toolHint string, basePriority []string) []string { + var preferred string + switch toolHint { + case "pip", "pipenv": + preferred = "requirements.txt" + case "uv", "poetry", "hatch", "pdm", "maturin", "scikit-build-core", "scikit-build": + preferred = "pyproject.toml" + case "setuptools": + preferred = "setup.cfg" + default: + // Unknown tool hint, use default priority + return basePriority + } + + // Find the preferred manifest in the base priority + var found bool + var newPriority []string + for _, name := range basePriority { + if name == preferred { + found = true + newPriority = append([]string{preferred}, newPriority...) + } else { + newPriority = append(newPriority, name) + } + } + + if !found { + // Preferred manifest not in base priority, return base as-is + return basePriority + } + + return newPriority +} diff --git a/pkg/languages/python/detector_test.go b/pkg/languages/python/detector_test.go new file mode 100644 index 0000000..04b3cb9 --- /dev/null +++ b/pkg/languages/python/detector_test.go @@ -0,0 +1,196 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- DetectManifestWithHint --- + +func TestDetectManifestWithHint_NoHint(t *testing.T) { + // Without hint, should use default priority (pyproject.toml first) + info, err := DetectManifestWithHint("testdata/hatch-pyproject", "") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) + assert.Equal(t, BuildToolHatch, info.BuildTool) +} + +func TestDetectManifestWithHint_PipHint(t *testing.T) { + // With pip hint, should prefer requirements.txt + // testdata/pip-requirements has both requirements.txt and (implicitly) no pyproject + info, err := DetectManifestWithHint("testdata/pip-requirements", "pip") + require.NoError(t, err) + assert.Equal(t, "requirements.txt", info.Type) + assert.Equal(t, BuildToolPip, info.BuildTool) +} + +func TestDetectManifestWithHint_PoetryHint(t *testing.T) { + // With poetry hint, should prefer pyproject.toml (and detect Poetry) + info, err := DetectManifestWithHint("testdata/poetry-pyproject", "poetry") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) + assert.Equal(t, BuildToolPoetry, info.BuildTool) +} + +func TestDetectManifestWithHint_SetuptoolsHint(t *testing.T) { + // With setuptools hint, should prefer setup.cfg + info, err := DetectManifestWithHint("testdata/setup-cfg", "setuptools") + require.NoError(t, err) + assert.Equal(t, "setup.cfg", info.Type) + assert.Equal(t, BuildToolSetuptools, info.BuildTool) +} + +func TestDetectManifestWithHint_UnknownHint(t *testing.T) { + // Unknown hint should fall back to default priority + info, err := DetectManifestWithHint("testdata/poetry-pyproject", "unknown-tool") + require.NoError(t, err) + // Should still detect poetry since pyproject.toml exists + assert.Equal(t, "pyproject.toml", info.Type) + assert.Equal(t, BuildToolPoetry, info.BuildTool) +} + +func TestDetectManifestWithHint_HatchHint(t *testing.T) { + // With hatch hint, prefer pyproject.toml + info, err := DetectManifestWithHint("testdata/hatch-pyproject", "hatch") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) + assert.Equal(t, BuildToolHatch, info.BuildTool) +} + +func TestDetectManifestWithHint_MaturinHint(t *testing.T) { + // Maturin hint → pyproject.toml + info, err := DetectManifestWithHint("testdata/maturin-project", "maturin") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) + assert.Equal(t, BuildToolMaturin, info.BuildTool) +} + +func TestDetectManifestWithHint_PDMHint(t *testing.T) { + // PDM hint → pyproject.toml (though no specific PDM test data, pyproject should work) + info, err := DetectManifestWithHint("testdata/hatch-pyproject", "pdm") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) +} + +func TestDetectManifestWithHint_PipenvHint(t *testing.T) { + // Pipenv hint → Pipfile (if it exists; otherwise fall back) + // Since testdata doesn't have Pipfile, should fall back to default priority + info, err := DetectManifestWithHint("testdata/poetry-pyproject", "pipenv") + require.NoError(t, err) + // Should detect whatever is available (in this case, pyproject.toml) + assert.Equal(t, "pyproject.toml", info.Type) +} + +// --- reorderManifestPriority --- + +func TestReorderManifestPriority_PipHint(t *testing.T) { + base := []string{"pyproject.toml", "requirements.txt", "setup.cfg", "setup.py", "Pipfile"} + reordered := reorderManifestPriority("pip", base) + + // requirements.txt should be first + assert.Equal(t, "requirements.txt", reordered[0]) + // Rest should be in some order + assert.Len(t, reordered, len(base)) +} + +func TestReorderManifestPriority_PyprojectHint(t *testing.T) { + base := []string{"pyproject.toml", "requirements.txt", "setup.cfg", "setup.py", "Pipfile"} + reordered := reorderManifestPriority("poetry", base) + + // pyproject.toml should be first (and was already first) + assert.Equal(t, "pyproject.toml", reordered[0]) + assert.Len(t, reordered, len(base)) +} + +func TestReorderManifestPriority_SetuptoolsHint(t *testing.T) { + base := []string{"pyproject.toml", "requirements.txt", "setup.cfg", "setup.py", "Pipfile"} + reordered := reorderManifestPriority("setuptools", base) + + // setup.cfg should be first + assert.Equal(t, "setup.cfg", reordered[0]) + assert.Len(t, reordered, len(base)) + // setup.py should be after setup.cfg (if present) + assert.Contains(t, reordered, "setup.py") +} + +func TestReorderManifestPriority_UnknownHint(t *testing.T) { + base := []string{"pyproject.toml", "requirements.txt", "setup.cfg", "setup.py", "Pipfile"} + reordered := reorderManifestPriority("xyz-tool", base) + + // Unknown hint should return base unchanged + assert.Equal(t, base, reordered) +} + +// --- Tool hints for various build tools --- + +func TestDetectManifestWithHint_AllPyprojectTools(t *testing.T) { + tools := []string{"poetry", "hatch", "uv", "pdm", "maturin", "scikit-build-core", "scikit-build"} + + for _, tool := range tools { + t.Run(tool, func(t *testing.T) { + info, err := DetectManifestWithHint("testdata/hatch-pyproject", tool) + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type, "tool %s should prefer pyproject.toml", tool) + }) + } +} + +// --- Real-world scenario: tool detection fallback --- + +func TestDetectManifestWithHint_ToolHintFallback(t *testing.T) { + // Scenario: user specifies --tool pip, but only pyproject.toml exists + // Should still work (find pyproject.toml via default priority) + dir := t.TempDir() + pyproject := filepath.Join(dir, "pyproject.toml") + require.NoError(t, os.WriteFile(pyproject, []byte(`[project] +name = "test" +version = "0.0.1" +dependencies = ["requests==2.28.0"] +`), 0o600)) + + // Even with pip hint, pyproject.toml should be detected + info, err := DetectManifestWithHint(dir, "pip") + require.NoError(t, err) + assert.Equal(t, "pyproject.toml", info.Type) +} + +// --- Manifest detection priority tests --- + +func TestDetectManifest_PriorityOrder(t *testing.T) { + // When multiple manifest files exist, pyproject.toml wins + dir := t.TempDir() + + // Create both requirements.txt and setup.cfg + reqs := filepath.Join(dir, "requirements.txt") + setup := filepath.Join(dir, "setup.cfg") + require.NoError(t, os.WriteFile(reqs, []byte("requests==2.28.0\n"), 0o600)) + require.NoError(t, os.WriteFile(setup, []byte("[metadata]\nname = test\n"), 0o600)) + + info, err := DetectManifest(dir) + require.NoError(t, err) + // requirements.txt comes before setup.cfg in priority + assert.Equal(t, "requirements.txt", info.Type) +} + +func TestDetectManifest_FallbackChain(t *testing.T) { + // When pyproject.toml doesn't exist, should check requirements.txt, setup.cfg, etc. + dir := t.TempDir() + + // Create only setup.cfg + setup := filepath.Join(dir, "setup.cfg") + require.NoError(t, os.WriteFile(setup, []byte("[metadata]\nname = test\n"), 0o600)) + + info, err := DetectManifest(dir) + require.NoError(t, err) + assert.Equal(t, "setup.cfg", info.Type) + assert.Equal(t, BuildToolSetuptools, info.BuildTool) +} diff --git a/pkg/languages/python/doc.go b/pkg/languages/python/doc.go new file mode 100644 index 0000000..6269f94 --- /dev/null +++ b/pkg/languages/python/doc.go @@ -0,0 +1,24 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package python implements omnibump support for Python projects. +// +// It supports both manifest mode (editing pyproject.toml, requirements.txt, +// setup.cfg, or Pipfile in-place) and venv mode (upgrading packages directly +// in a staged Python virtualenv). +// +// Manifest mode auto-detects build tools (pip, uv, poetry, hatch, pdm, maturin, +// scikit-build-core, setuptools) and can be overridden with the tool hint. +// It handles multiple manifest file formats: PEP 621 pyproject.toml (with Poetry, +// Hatch, and other backends), requirements.txt, setup.cfg, and Pipfile. +// +// Venv mode is designed for application/leaf packages that bundle dependencies +// in a staged virtualenv. It validates strict == pinning, rejects downgrades, +// and verifies environment consistency with pip check. It supports both uv pip +// and standard pip installers. +// +// Both modes integrate with omnibump's language interface for unified +// dependency version bumping across multiple ecosystems. +package python diff --git a/pkg/languages/python/example_test.go b/pkg/languages/python/example_test.go new file mode 100644 index 0000000..4114d93 --- /dev/null +++ b/pkg/languages/python/example_test.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python_test + +import ( + "fmt" + + "github.com/chainguard-dev/omnibump/pkg/languages/python" +) + +// Example_detectManifest demonstrates detecting a Python manifest file. +func Example_detectManifest() { + // In a real scenario, you would have a project directory with pyproject.toml + // For example purposes, we just show the API + fmt.Println("Use DetectManifest to find Python manifest files in a project directory") + // Output: Use DetectManifest to find Python manifest files in a project directory +} + +// ExamplePython_Name demonstrates the Python language name. +func ExamplePython_Name() { + p := &python.Python{} + fmt.Println(p.Name()) + // Output: python +} + +// ExampleNewVersionResolver demonstrates creating a version resolver. +func ExampleNewVersionResolver() { + resolver := python.NewVersionResolver() + fmt.Printf("VersionResolver created: %T\n", resolver) + // Output: VersionResolver created: *python.VersionResolver +} diff --git a/pkg/languages/python/pdm.go b/pkg/languages/python/pdm.go new file mode 100644 index 0000000..2e8c3d8 --- /dev/null +++ b/pkg/languages/python/pdm.go @@ -0,0 +1,13 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +// pdm projects declare dependencies in pyproject.toml [project].dependencies (PEP 621). +// omnibump updates pyproject.toml directly. The user must re-run `pdm lock` afterwards +// to regenerate pdm.lock from the updated constraints. +// +// pdm.lock detection is used to identify the build tool but the lockfile itself +// is not modified by omnibump. diff --git a/pkg/languages/python/pipfile.go b/pkg/languages/python/pipfile.go new file mode 100644 index 0000000..bcd2bd8 --- /dev/null +++ b/pkg/languages/python/pipfile.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/BurntSushi/toml" +) + +// pipfileDoc represents the dependency-relevant sections of a Pipfile. +type pipfileDoc struct { + Packages map[string]interface{} `toml:"packages"` + DevPackages map[string]interface{} `toml:"dev-packages"` +} + +// ParsePipfile parses [packages] and [dev-packages] from a Pipfile. +func ParsePipfile(data []byte) ([]VersionSpec, error) { + var doc pipfileDoc + if err := toml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing Pipfile: %w", err) + } + + var specs []VersionSpec + for section, deps := range map[string]map[string]interface{}{ + "packages": doc.Packages, + "dev-packages": doc.DevPackages, + } { + _ = section + for name, val := range deps { + var ver string + switch v := val.(type) { + case string: + ver = v + case map[string]interface{}: + if s, ok := v["version"].(string); ok { + ver = s + } + } + if ver == "" || ver == "*" { + continue + } + _, op, version := splitSpecifier(ver) + specs = append(specs, VersionSpec{ + Package: normalizePkgName(name), + Specifier: op, + Version: version, + RawLine: fmt.Sprintf("%s = %q", name, ver), + }) + } + } + return specs, nil +} + +// UpdatePipfile updates the version of a named package in a Pipfile. +// The existing operator is preserved. newVersion is a bare version number. +func UpdatePipfile(path, packageName, newVersion string) error { + if err := validatePythonVersion(newVersion); err != nil { + return err + } + if err := validateManifestPath(path); err != nil { + return err + } + + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + norm := normalizePkgName(packageName) + lines := strings.Split(string(data), "\n") + found := false + + // Match: pkg = "specifier" in Pipfile (TOML style) + lineRe := regexp.MustCompile(`(?i)^(\s*)([A-Z0-9][A-Z0-9._-]*)(\s*=\s*")([><=!~^]?)([0-9][^"]*)(".*$)`) + + for i, raw := range lines { + m := lineRe.FindStringSubmatchIndex(raw) + if m == nil { + continue + } + name := raw[m[4]:m[5]] + if normalizePkgName(name) != norm { + continue + } + // Replace the version digits (m[10]:m[11]) + lines[i] = raw[:m[10]] + newVersion + raw[m[11]:] + found = true + } + + if !found { + return fmt.Errorf("%w: %s", ErrPackageNotFound, packageName) + } + return safeWriteFile(path, []byte(strings.Join(lines, "\n"))) +} diff --git a/pkg/languages/python/pyproject.go b/pkg/languages/python/pyproject.go new file mode 100644 index 0000000..0838b81 --- /dev/null +++ b/pkg/languages/python/pyproject.go @@ -0,0 +1,274 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/BurntSushi/toml" +) + +// pyprojectDoc represents the dependency-relevant sections of pyproject.toml. +type pyprojectDoc struct { + Project struct { + Dependencies []string `toml:"dependencies"` + } `toml:"project"` + Tool struct { + Poetry struct { + Dependencies map[string]interface{} `toml:"dependencies"` + } `toml:"poetry"` + } `toml:"tool"` +} + +// ParsePyprojectDeps parses dependencies from pyproject.toml content. +// For Poetry projects, it reads [tool.poetry.dependencies]. +// For all others (PEP 621 — hatch, maturin, setuptools, scikit-build-core, pdm, uv), +// it reads [project].dependencies. +func ParsePyprojectDeps(data []byte, buildTool BuildTool) ([]VersionSpec, error) { + var doc pyprojectDoc + if err := toml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing pyproject.toml: %w", err) + } + + if buildTool == BuildToolPoetry { + return parsePoetryDeps(doc.Tool.Poetry.Dependencies), nil + } + return parsePEP621Deps(doc.Project.Dependencies), nil +} + +// parsePEP621Deps parses the PEP 621 [project].dependencies list. +// Each entry is a PEP 508 dependency specifier string. +func parsePEP621Deps(deps []string) []VersionSpec { + specs := make([]VersionSpec, 0, len(deps)) + for _, dep := range deps { + spec := parsePEP508(strings.TrimSpace(dep)) + if spec != nil { + specs = append(specs, *spec) + } + } + return specs +} + +// parsePoetryDeps parses [tool.poetry.dependencies]. +// Values may be strings ("^1.0") or inline tables ({version = "^1.0", optional = true}). +func parsePoetryDeps(deps map[string]interface{}) []VersionSpec { + specs := make([]VersionSpec, 0, len(deps)) + for name, val := range deps { + if name == "python" { + continue + } + var ver string + switch v := val.(type) { + case string: + ver = v + case map[string]interface{}: + if s, ok := v["version"].(string); ok { + ver = s + } + } + if ver == "" { + continue + } + full, op, version := splitSpecifier(ver) + specs = append(specs, VersionSpec{ + Package: normalizePkgName(name), + Specifier: op, + Version: version, + RawLine: fmt.Sprintf("%s = %q", name, full), + }) + } + return specs +} + +// pep508Re matches a PEP 508 dependency string: name [extras] [specifier] [; marker]. +var pep508Re = regexp.MustCompile(`(?i)^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)\s*(?:\[[^\]]*\])?\s*([><=!~^][^;]*)?`) + +// parsePEP508 parses a single PEP 508 specifier string. +func parsePEP508(s string) *VersionSpec { + m := pep508Re.FindStringSubmatch(s) + if m == nil { + return nil + } + name := m[1] + rawSpec := strings.TrimSpace(m[3]) + _, op, version := splitSpecifier(rawSpec) + return &VersionSpec{ + Package: normalizePkgName(name), + Specifier: op, + Version: version, + RawLine: s, + } +} + +// splitSpecifier splits a version specifier like ">=2.28.0" into (">=", "2.28.0"). +// For compound specifiers like ">=1.0,<2.0" it returns the full string as specifier. +func splitSpecifier(s string) (full, op, version string) { + s = strings.TrimSpace(s) + if s == "" { + return "", "", "" + } + // Compound specifier (contains comma) + if strings.Contains(s, ",") { + return s, s, "" + } + opRe := regexp.MustCompile(`^([><=!~^]+)(.+)$`) + m := opRe.FindStringSubmatch(s) + if m == nil { + return s, "", s + } + return s, m[1], strings.TrimSpace(m[2]) +} + +// UpdatePyprojectDep updates the version of a named dependency in pyproject.toml. +// It preserves comments and formatting by operating on the raw file text. +// newVersion should be just the version number (e.g. "2.32.0"), not include an operator. +// The existing operator is preserved. +func UpdatePyprojectDep(path, packageName, newVersion string) error { + if err := validatePythonVersion(newVersion); err != nil { + return err + } + if err := validateManifestPath(path); err != nil { + return err + } + + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + // Detect the build tool to know which section to update. + bt, _ := DetectBuildToolFromPyproject(path) + + updated, found := false, false + if bt == BuildToolPoetry { + updated, err = updatePoetryDep(data, path, packageName, newVersion) + found = updated + } else { + updated, err = updatePEP621Dep(data, path, packageName, newVersion) + found = updated + } + + if err != nil { + return err + } + if !found { + return fmt.Errorf("%w: %s", ErrPackageNotFound, packageName) + } + return nil +} + +// updatePEP621Dep updates a dep in [project].dependencies list. +// Lines look like: "requests>=2.28.0". +func updatePEP621Dep(data []byte, path, pkg, newVersion string) (bool, error) { + lines := strings.Split(string(data), "\n") + norm := normalizePkgName(pkg) + found := false + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + // Strip surrounding quotes and trailing comma for matching + inner := strings.Trim(trimmed, `"',`) + spec := parsePEP508(inner) + if spec == nil || normalizePkgName(spec.Package) != norm { + continue + } + // Rebuild the specifier with the new version + newInner := rebuildPEP508(inner, spec, newVersion) + lines[i] = strings.Replace(line, inner, newInner, 1) + found = true + } + + if !found { + return false, nil + } + return true, safeWriteFile(path, []byte(strings.Join(lines, "\n"))) +} + +// updatePoetryDep updates a dep in [tool.poetry.dependencies]. +// Lines look like: requests = "^2.28.0". +func updatePoetryDep(data []byte, path, pkg, newVersion string) (bool, error) { + lines := strings.Split(string(data), "\n") + norm := normalizePkgName(pkg) + found := false + + // Match: pkg = "specifier" or pkg = {version = "specifier", ...} + simpleRe := regexp.MustCompile(`(?i)^(\s*)([A-Z0-9][A-Z0-9._-]*)(\s*=\s*")([><=!~^]*)([0-9][^"]*)(".*$)`) + tableRe := regexp.MustCompile(`(?i)^(\s*)([A-Z0-9][A-Z0-9._-]*)(\s*=\s*\{[^}]*version\s*=\s*")([><=!~^]*)([0-9][^"]*)(".*$)`) + + for i, line := range lines { + for _, re := range []*regexp.Regexp{simpleRe, tableRe} { + m := re.FindStringSubmatchIndex(line) + if m == nil { + continue + } + // m[4] = start of name, m[5] = end of name + name := line[m[4]:m[5]] + if normalizePkgName(name) != norm { + continue + } + // m[10] = start of version digits, m[11] = end + lines[i] = line[:m[10]] + newVersion + line[m[11]:] + found = true + break + } + } + + if !found { + return false, nil + } + return true, safeWriteFile(path, []byte(strings.Join(lines, "\n"))) +} + +// rebuildPEP508 reconstructs a PEP 508 specifier with a new version. +// The existing operator is preserved; compound specifiers are replaced wholesale. +func rebuildPEP508(original string, spec *VersionSpec, newVersion string) string { + if spec.Specifier == "" { + return original // no version specifier — leave unchanged + } + // For compound specifiers, replace first version occurrence only + if strings.Contains(spec.Specifier, ",") { + // Replace the first version number in the specifier + vRe := regexp.MustCompile(`([><=!~^]+)\s*([0-9][^\s,;"]*)`) + replaced := false + result := vRe.ReplaceAllStringFunc(original, func(match string) string { + if replaced { + return match + } + replaced = true + m := vRe.FindStringSubmatch(match) + return m[1] + newVersion + }) + return result + } + // Simple specifier: replace version after the operator + vRe := regexp.MustCompile(`([><=!~^]+)\s*([0-9][^\s,;"]*)`) + return vRe.ReplaceAllStringFunc(original, func(match string) string { + m := vRe.FindStringSubmatch(match) + return m[1] + newVersion + }) +} + +// normalizePkgName normalises a Python package name per PEP 503: +// lowercase, with runs of [-_.] replaced by a single hyphen. +func normalizePkgName(name string) string { + name = strings.ToLower(name) + re := regexp.MustCompile(`[-_.]+`) + return re.ReplaceAllString(name, "-") +} + +// validatePythonVersion checks that a version string is safe to write into a manifest file. +// Allows: digits, dots, letters, hyphens, underscores, plus signs. +func validatePythonVersion(v string) error { + re := regexp.MustCompile(`^[a-zA-Z0-9._+\-]+$`) + if !re.MatchString(v) { + return fmt.Errorf("%w: %q", ErrInvalidVersion, v) + } + return nil +} diff --git a/pkg/languages/python/python.go b/pkg/languages/python/python.go new file mode 100644 index 0000000..9b99f1f --- /dev/null +++ b/pkg/languages/python/python.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "context" + "errors" + "fmt" + + "github.com/chainguard-dev/clog" + "github.com/chainguard-dev/omnibump/pkg/languages" +) + +func init() { + languages.Register(&Python{}) +} + +// Python implements the Language interface for Python projects. +// It auto-detects the build tool and delegates updates to the appropriate handler. +type Python struct{} + +// Verify Python implements the languages.Language interface at compile time. +var _ languages.Language = (*Python)(nil) + +// Name returns the language identifier. +func (p *Python) Name() string { + return "python" +} + +// Detect checks whether a Python manifest file exists in dir. +func (p *Python) Detect(_ context.Context, dir string) (bool, error) { + _, err := DetectManifest(dir) + if errors.Is(err, ErrManifestNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// GetManifestFiles returns all Python manifest filenames omnibump can process. +func (p *Python) GetManifestFiles() []string { + return []string{ + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "Pipfile", + } +} + +// SupportsAnalysis returns true since Python has full analysis capabilities. +func (p *Python) SupportsAnalysis() bool { + return true +} + +// Update performs dependency version updates on a Python project. +// For each dependency in cfg.Dependencies, it locates the highest-priority +// manifest and updates the version in-place. +// If Options["venv"] is set, uses venv mode (uv/pip install into a staged venv). +// Otherwise uses manifest mode (edit pyproject.toml, requirements.txt, etc. in-place). +func (p *Python) Update(ctx context.Context, cfg *languages.UpdateConfig) error { + log := clog.FromContext(ctx) + + // Check for venv mode + var venvPath string + if v, ok := cfg.Options["venv"]; ok { + venvPath = v.(string) + } + + // If venv mode is specified, use venv bumping instead of manifest editing + if venvPath != "" { + return updateVenv(ctx, cfg, venvPath) + } + + // Otherwise, use manifest mode + var toolHint string + if t, ok := cfg.Options["tool"]; ok { + toolHint = t.(string) + } + + manifest, err := DetectManifestWithHint(cfg.RootDir, toolHint) + if err != nil { + return fmt.Errorf("detecting Python manifest in %s: %w", cfg.RootDir, err) + } + + log.Infof("Detected Python build tool: %s (manifest: %s)", manifest.BuildTool, manifest.Type) + + for _, dep := range cfg.Dependencies { + if cfg.DryRun { + log.Infof("[dry-run] would update %s to %s in %s", dep.Name, dep.Version, manifest.Path) + continue + } + + if err := updateDepInManifest(manifest, dep.Name, dep.Version); err != nil { + return fmt.Errorf("updating %s to %s: %w", dep.Name, dep.Version, err) + } + log.Infof("Updated %s to %s in %s", dep.Name, dep.Version, manifest.Path) + } + + if manifest.BuildTool == BuildToolUV { + log.Warnf("uv project: re-run 'uv lock' to regenerate uv.lock after updating pyproject.toml") + } + if manifest.BuildTool == BuildToolPDM { + log.Warnf("pdm project: re-run 'pdm lock' to regenerate pdm.lock after updating pyproject.toml") + } + + return nil +} + +// Validate checks that each dependency was updated to the expected version. +// If Options["venv"] is set, validates versions in the venv. +// Otherwise validates versions in the manifest file. +func (p *Python) Validate(ctx context.Context, cfg *languages.UpdateConfig) error { + log := clog.FromContext(ctx) + + // Check for venv mode + var venvPath string + if v, ok := cfg.Options["venv"]; ok { + venvPath = v.(string) + } + + // If venv mode is specified, validate venv instead of manifest + if venvPath != "" { + return validateVenv(ctx, cfg, venvPath) + } + + // Otherwise, use manifest mode + var toolHint string + if t, ok := cfg.Options["tool"]; ok { + toolHint = t.(string) + } + + manifest, err := DetectManifestWithHint(cfg.RootDir, toolHint) + if err != nil { + return fmt.Errorf("detecting Python manifest in %s: %w", cfg.RootDir, err) + } + + specs, err := readSpecsFromManifest(manifest) + if err != nil { + return err + } + + // Build a lookup map of package → current version + current := make(map[string]string, len(specs)) + for _, s := range specs { + current[s.Package] = s.Version + } + + for _, dep := range cfg.Dependencies { + norm := normalizePkgName(dep.Name) + got, ok := current[norm] + if !ok { + return fmt.Errorf("validation: %w: %s", ErrPackageNotFound, dep.Name) + } + if got != dep.Version { + log.Warnf("validation: %s expected %s but found %s", dep.Name, dep.Version, got) + return fmt.Errorf("validation failed: %s expected %s, got %s: %w", dep.Name, dep.Version, got, ErrInvalidVersion) + } + log.Debugf("validation ok: %s == %s", dep.Name, dep.Version) + } + + return nil +} + +// updateDepInManifest routes the update to the correct file handler. +func updateDepInManifest(manifest *ManifestInfo, pkg, version string) error { + switch manifest.Type { + case "pyproject.toml": + return UpdatePyprojectDep(manifest.Path, pkg, version) + case "requirements.txt": + return UpdateRequirement(manifest.Path, pkg, version) + case "setup.cfg": + return UpdateSetupCfg(manifest.Path, pkg, version) + case "Pipfile": + return UpdatePipfile(manifest.Path, pkg, version) + case ManifestSetupPy: + return ErrSetupPyReadOnly + default: + return fmt.Errorf("%w: %s", ErrUnsupportedManifestType, manifest.Type) + } +} diff --git a/pkg/languages/python/python_test.go b/pkg/languages/python/python_test.go new file mode 100644 index 0000000..ae2be7d --- /dev/null +++ b/pkg/languages/python/python_test.go @@ -0,0 +1,218 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainguard-dev/omnibump/pkg/languages/python" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- DetectManifest --- + +func TestDetectManifest_Pyproject(t *testing.T) { + for _, tt := range []struct { + dir string + wantTool python.BuildTool + wantType string + }{ + {"testdata/hatch-pyproject", python.BuildToolHatch, "pyproject.toml"}, + {"testdata/poetry-pyproject", python.BuildToolPoetry, "pyproject.toml"}, + {"testdata/maturin-project", python.BuildToolMaturin, "pyproject.toml"}, + {"testdata/scikit-build-core", python.BuildToolScikitBuildCore, "pyproject.toml"}, + } { + t.Run(tt.dir, func(t *testing.T) { + info, err := python.DetectManifest(tt.dir) + require.NoError(t, err) + assert.Equal(t, tt.wantType, info.Type) + assert.Equal(t, tt.wantTool, info.BuildTool) + }) + } +} + +func TestDetectManifest_Requirements(t *testing.T) { + info, err := python.DetectManifest("testdata/pip-requirements") + require.NoError(t, err) + assert.Equal(t, "requirements.txt", info.Type) + assert.Equal(t, python.BuildToolPip, info.BuildTool) +} + +func TestDetectManifest_SetupCfg(t *testing.T) { + info, err := python.DetectManifest("testdata/setup-cfg") + require.NoError(t, err) + assert.Equal(t, "setup.cfg", info.Type) + assert.Equal(t, python.BuildToolSetuptools, info.BuildTool) +} + +func TestDetectManifest_NotFound(t *testing.T) { + _, err := python.DetectManifest(t.TempDir()) + assert.ErrorIs(t, err, python.ErrManifestNotFound) +} + +// --- ParsePyprojectDeps --- + +func TestParsePyprojectDeps_Hatch(t *testing.T) { + data, err := os.ReadFile("testdata/hatch-pyproject/pyproject.toml") + require.NoError(t, err) + + specs, err := python.ParsePyprojectDeps(data, python.BuildToolHatch) + require.NoError(t, err) + require.Len(t, specs, 3) + + names := make(map[string]python.VersionSpec) + for _, s := range specs { + names[s.Package] = s + } + + assert.Equal(t, "2.28.0", names["requests"].Version) + assert.Equal(t, ">=", names["requests"].Specifier) + assert.Equal(t, "39.0.1", names["cryptography"].Version) +} + +func TestParsePyprojectDeps_Poetry(t *testing.T) { + data, err := os.ReadFile("testdata/poetry-pyproject/pyproject.toml") + require.NoError(t, err) + + specs, err := python.ParsePyprojectDeps(data, python.BuildToolPoetry) + require.NoError(t, err) + + names := make(map[string]python.VersionSpec) + for _, s := range specs { + names[s.Package] = s + } + + assert.Equal(t, "2.28.0", names["requests"].Version) + _, hasPython := names["python"] + assert.False(t, hasPython, "python itself should be excluded") +} + +// --- ParseRequirements --- + +func TestParseRequirements(t *testing.T) { + data, err := os.ReadFile("testdata/pip-requirements/requirements.txt") + require.NoError(t, err) + + specs := python.ParseRequirements(data) + require.NotEmpty(t, specs) + + names := make(map[string]python.VersionSpec) + for _, s := range specs { + names[s.Package] = s + } + + assert.Equal(t, "2.28.2", names["requests"].Version) + assert.Equal(t, "==", names["requests"].Specifier) + assert.Equal(t, "7.2.0", names["pytest"].Version) +} + +func TestParseRequirements_SkipsComments(t *testing.T) { + data := []byte("# this is a comment\nrequests==2.28.2\n") + specs := python.ParseRequirements(data) + require.Len(t, specs, 1) + assert.Equal(t, "requests", specs[0].Package) +} + +// --- UpdateRequirement --- + +func TestUpdateRequirement(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "requirements.txt") + require.NoError(t, os.WriteFile(path, []byte("requests==2.28.2\nurllib3>=1.26.0,<2.0\n"), 0o600)) + + require.NoError(t, python.UpdateRequirement(path, "requests", "2.32.0")) + + updated, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(updated), "requests==2.32.0") + // urllib3 should be unchanged + assert.Contains(t, string(updated), "urllib3>=1.26.0,<2.0") +} + +func TestUpdateRequirement_PackageNotFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "requirements.txt") + require.NoError(t, os.WriteFile(path, []byte("requests==2.28.2\n"), 0o600)) + + err := python.UpdateRequirement(path, "nonexistent", "1.0.0") + assert.ErrorIs(t, err, python.ErrPackageNotFound) +} + +func TestUpdateRequirement_InvalidVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "requirements.txt") + require.NoError(t, os.WriteFile(path, []byte("requests==2.28.2\n"), 0o600)) + + err := python.UpdateRequirement(path, "requests", "2.32.0; rm -rf /") + assert.ErrorIs(t, err, python.ErrInvalidVersion) +} + +// --- UpdatePyprojectDep --- + +func TestUpdatePyprojectDep_Hatch(t *testing.T) { + src, err := os.ReadFile("testdata/hatch-pyproject/pyproject.toml") + require.NoError(t, err) + + dir := t.TempDir() + path := filepath.Join(dir, "pyproject.toml") + require.NoError(t, os.WriteFile(path, src, 0o600)) + + require.NoError(t, python.UpdatePyprojectDep(path, "requests", "2.32.0")) + + updated, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(updated), "2.32.0") + // cryptography should be unchanged + assert.Contains(t, string(updated), "39.0.1") +} + +func TestUpdatePyprojectDep_Poetry(t *testing.T) { + src, err := os.ReadFile("testdata/poetry-pyproject/pyproject.toml") + require.NoError(t, err) + + dir := t.TempDir() + path := filepath.Join(dir, "pyproject.toml") + require.NoError(t, os.WriteFile(path, src, 0o600)) + + require.NoError(t, python.UpdatePyprojectDep(path, "cryptography", "41.0.0")) + + updated, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(updated), "41.0.0") +} + +// --- ParseSetupCfg --- + +func TestParseSetupCfg(t *testing.T) { + data, err := os.ReadFile("testdata/setup-cfg/setup.cfg") + require.NoError(t, err) + + specs := python.ParseSetupCfg(data) + require.NotEmpty(t, specs) + + names := make(map[string]python.VersionSpec) + for _, s := range specs { + names[s.Package] = s + } + + assert.Equal(t, "2.28.0", names["requests"].Version) + assert.Equal(t, "39.0.1", names["cryptography"].Version) +} + +// --- normalizePkgName (via ParseRequirements) --- + +func TestNormalizePkgName(t *testing.T) { + // PEP 503: dashes, underscores, dots are all equivalent + data := []byte("Pillow==9.0.0\nPIL_Image==1.0.0\npil.image==2.0.0\n") + specs := python.ParseRequirements(data) + for _, s := range specs { + // All three should normalize to "pillow" or "pil-image" + assert.NotEmpty(t, s.Package) + } +} diff --git a/pkg/languages/python/registry.go b/pkg/languages/python/registry.go new file mode 100644 index 0000000..d9de45e --- /dev/null +++ b/pkg/languages/python/registry.go @@ -0,0 +1,210 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "sort" + "strings" + "time" +) + +const ( + // envRegistryURL is the env var for overriding the Chainguard Python registry base URL. + envRegistryURL = "CHAINGUARD_PYTHON_REGISTRY_URL" + // envToken is the env var for the Chainguard auth token. + envToken = "CHAINGUARD_TOKEN" + // pypiBaseURL is the PyPI JSON API base. + pypiBaseURL = "https://pypi.org/pypi" + // defaultTimeout for HTTP requests. + defaultTimeout = 15 * time.Second +) + +// VersionResolver resolves Python package versions. +// It checks the Chainguard GAR-backed registry first, then falls back to PyPI. +type VersionResolver struct { + registryBaseURL string + token string + httpClient *http.Client +} + +// NewVersionResolver creates a VersionResolver from environment variables. +func NewVersionResolver() *VersionResolver { + return &VersionResolver{ + registryBaseURL: os.Getenv(envRegistryURL), + token: os.Getenv(envToken), + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +// GetLatestVersion returns the latest available version of a Python package. +// Checks the Chainguard registry first; falls back to PyPI. +func (r *VersionResolver) GetLatestVersion(ctx context.Context, pkg string) (string, error) { + norm := normalizePkgName(pkg) + + if r.registryBaseURL != "" { + ver, err := r.latestFromRegistry(ctx, norm) + if err == nil && ver != "" { + return ver, nil + } + } + + return r.latestFromPyPI(ctx, norm) +} + +// VersionExists checks whether a specific version of a package is available. +func (r *VersionResolver) VersionExists(ctx context.Context, pkg, version string) (bool, error) { + norm := normalizePkgName(pkg) + + if r.registryBaseURL != "" { + exists, err := r.versionExistsInRegistry(ctx, norm, version) + if err == nil { + return exists, nil + } + } + + return r.versionExistsInPyPI(ctx, norm, version) +} + +// latestFromRegistry queries the Chainguard Python Simple Index for the latest version. +// The Simple Index (PEP 503) returns an HTML page with links whose filenames encode versions. +func (r *VersionResolver) latestFromRegistry(ctx context.Context, pkg string) (string, error) { + url := fmt.Sprintf("%s/simple/%s/", strings.TrimRight(r.registryBaseURL, "/"), pkg) + body, err := r.get(ctx, url) + if err != nil { + return "", err + } + versions := extractVersionsFromSimpleIndex(body) + if len(versions) == 0 { + return "", fmt.Errorf("%w: %s", ErrVersionNotFound, pkg) + } + return latestVersion(versions), nil +} + +// versionExistsInRegistry checks the Simple Index for a specific version. +func (r *VersionResolver) versionExistsInRegistry(ctx context.Context, pkg, version string) (bool, error) { + url := fmt.Sprintf("%s/simple/%s/", strings.TrimRight(r.registryBaseURL, "/"), pkg) + body, err := r.get(ctx, url) + if err != nil { + return false, err + } + versions := extractVersionsFromSimpleIndex(body) + for _, v := range versions { + if v == version { + return true, nil + } + } + return false, nil +} + +// pypiJSON is the minimal structure we need from the PyPI JSON API response. +type pypiJSON struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + Releases map[string]interface{} `json:"releases"` +} + +// latestFromPyPI queries https://pypi.org/pypi/{pkg}/json for the latest version. +func (r *VersionResolver) latestFromPyPI(ctx context.Context, pkg string) (string, error) { + url := fmt.Sprintf("%s/%s/json", pypiBaseURL, pkg) + body, err := r.get(ctx, url) + if err != nil { + return "", fmt.Errorf("PyPI query for %s: %w", pkg, err) + } + + var result pypiJSON + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parsing PyPI response for %s: %w", pkg, err) + } + if result.Info.Version == "" { + return "", fmt.Errorf("%w: %s", ErrInvalidVersionResponse, pkg) + } + return result.Info.Version, nil +} + +// versionExistsInPyPI checks if a version exists in the PyPI releases map. +func (r *VersionResolver) versionExistsInPyPI(ctx context.Context, pkg, version string) (bool, error) { + url := fmt.Sprintf("%s/%s/json", pypiBaseURL, pkg) + body, err := r.get(ctx, url) + if err != nil { + return false, fmt.Errorf("PyPI query for %s: %w", pkg, err) + } + + var result pypiJSON + if err := json.Unmarshal(body, &result); err != nil { + return false, fmt.Errorf("parsing PyPI response for %s: %w", pkg, err) + } + _, exists := result.Releases[version] + return exists, nil +} + +// get performs a GET request, adding the Bearer token only to Chainguard registry requests. +func (r *VersionResolver) get(ctx context.Context, urlStr string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, err + } + + // Only add the token to requests to the Chainguard registry, not to PyPI or other hosts + if r.token != "" && r.registryBaseURL != "" { + registryURL, err := url.Parse(r.registryBaseURL) + if err == nil && registryURL.Hostname() == req.URL.Hostname() { + req.Header.Set("Authorization", "Bearer "+r.token) + } + } + + resp, err := r.httpClient.Do(req) // #nosec G704 - URL from Chainguard registry or PyPI base URL + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("%w: %s", ErrHTTPNotFound, urlStr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: status %d for %s", ErrUnexpectedHTTPStatus, resp.StatusCode, urlStr) + } + return io.ReadAll(resp.Body) +} + +// wheelFilenameRe extracts the version from a PEP 425 wheel filename or sdist tarball. +// Examples: requests-2.28.0-py3-none-any.whl, requests-2.28.0.tar.gz. +var wheelFilenameRe = regexp.MustCompile(`-([0-9][^-/!]+?)(?:-py|\.tar\.gz|\.zip|\.whl)`) + +// extractVersionsFromSimpleIndex parses wheel/sdist filenames from a PEP 503 Simple Index HTML page. +func extractVersionsFromSimpleIndex(html []byte) []string { + seen := make(map[string]bool) + var versions []string + for _, m := range wheelFilenameRe.FindAllSubmatch(html, -1) { + v := string(m[1]) + if !seen[v] { + seen[v] = true + versions = append(versions, v) + } + } + return versions +} + +// latestVersion returns the lexicographically largest version string. +// This is a simple approximation — for proper semver comparison use a semver library. +func latestVersion(versions []string) string { + if len(versions) == 0 { + return "" + } + sort.Strings(versions) + return versions[len(versions)-1] +} diff --git a/pkg/languages/python/requirements.go b/pkg/languages/python/requirements.go new file mode 100644 index 0000000..d8c6819 --- /dev/null +++ b/pkg/languages/python/requirements.go @@ -0,0 +1,123 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// reqLineRe matches a pip requirement line with an optional version specifier. +// It captures: (package)(extras)(specifier)(version)(rest — markers etc.) +var reqLineRe = regexp.MustCompile(`(?i)^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)(\[[^\]]*\])?\s*([><=!~^][^;#\n]*)?(.*)$`) + +// ParseRequirements parses a requirements.txt byte slice and returns dependency specs. +// Comment lines, blank lines, and option flags (-r, -c, -e, -i, --index-url) are skipped. +func ParseRequirements(data []byte) []VersionSpec { + var specs []VersionSpec + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + // Strip inline comment + if idx := strings.Index(line, " #"); idx != -1 { + line = strings.TrimSpace(line[:idx]) + } + m := reqLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + name := m[1] + rawSpec := strings.TrimSpace(m[4]) + op, version := splitReqSpecifier(rawSpec) + specs = append(specs, VersionSpec{ + Package: normalizePkgName(name), + Specifier: op, + Version: version, + RawLine: raw, + }) + } + return specs +} + +// splitReqSpecifier splits ">=2.28.0" into (">=", "2.28.0"). +// For compound specifiers (">=1.0,<2.0") returns the full string as specifier. +func splitReqSpecifier(s string) (op, version string) { + s = strings.TrimSpace(s) + if s == "" { + return "", "" + } + if strings.Contains(s, ",") { + return s, "" + } + re := regexp.MustCompile(`^([><=!~^]+)(.+)$`) + m := re.FindStringSubmatch(s) + if m == nil { + return "", s + } + return m[1], strings.TrimSpace(m[2]) +} + +// UpdateRequirement updates the version of the named package in a requirements.txt file. +// The existing version operator is preserved (==, >=, ~=, etc.). +// newVersion is the bare version number without an operator. +func UpdateRequirement(path, packageName, newVersion string) error { + if err := validatePythonVersion(newVersion); err != nil { + return err + } + if err := validateManifestPath(path); err != nil { + return err + } + + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + norm := normalizePkgName(packageName) + lines := strings.Split(string(data), "\n") + found := false + + // versionRe matches the operator(s) and version in a requirement line. + versionRe := regexp.MustCompile(`([><=!~^]+)\s*([0-9][^\s,;#]*)`) + + for i, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + m := reqLineRe.FindStringSubmatch(line) + if m == nil || normalizePkgName(m[1]) != norm { + continue + } + if m[4] == "" { + // No existing specifier — append ==newVersion + lines[i] = strings.TrimRight(raw, " \t") + "==" + newVersion + } else { + // Replace first version in the specifier, preserving the operator. + replaced := false + newSpec := versionRe.ReplaceAllStringFunc(m[4], func(match string) string { + if replaced { + return match + } + replaced = true + mm := versionRe.FindStringSubmatch(match) + return mm[1] + newVersion + }) + lines[i] = strings.Replace(raw, m[4], newSpec, 1) + } + found = true + } + + if !found { + return fmt.Errorf("%w: %s", ErrPackageNotFound, packageName) + } + return safeWriteFile(path, []byte(strings.Join(lines, "\n"))) +} diff --git a/pkg/languages/python/setup.go b/pkg/languages/python/setup.go new file mode 100644 index 0000000..5203771 --- /dev/null +++ b/pkg/languages/python/setup.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ParseSetupCfg parses install_requires from a setup.cfg file. +func ParseSetupCfg(data []byte) []VersionSpec { + var specs []VersionSpec + inInstallRequires := false + + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + + if strings.HasPrefix(line, "[") { + // New section — leave install_requires mode + if inInstallRequires && !strings.HasPrefix(line, "[options") { + inInstallRequires = false + } + } + + if strings.ToLower(line) == "install_requires =" || + strings.ToLower(line) == "install_requires=" { + inInstallRequires = true + continue + } + + // Also handle inline: install_requires = pkg>=1.0 + if parseInlineInstallRequires(raw, &specs) { + inInstallRequires = true + continue + } + + if inInstallRequires { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // A new key (not indented in original) ends the section + if raw != "" && raw[0] != ' ' && raw[0] != '\t' && !strings.HasPrefix(line, "#") { + inInstallRequires = false + continue + } + if spec := parsePEP508(line); spec != nil { + specs = append(specs, *spec) + } + } + } + return specs +} + +// ParseSetupPy extracts install_requires entries from setup.py using regex. +// This handles the common pattern: install_requires=["pkg>=1.0", ...] or +// install_requires=[ ... ] across multiple lines. +func ParseSetupPy(data []byte) []VersionSpec { + var specs []VersionSpec + // Match contents of install_requires=[...] (greedy, handles multi-line) + blockRe := regexp.MustCompile(`(?s)install_requires\s*=\s*\[([^\]]*)\]`) + m := blockRe.FindSubmatch(data) + if m == nil { + return specs + } + block := string(m[1]) + // Extract quoted strings from the block + quotedRe := regexp.MustCompile(`["']([^"']+)["']`) + for _, match := range quotedRe.FindAllStringSubmatch(block, -1) { + if spec := parsePEP508(strings.TrimSpace(match[1])); spec != nil { + specs = append(specs, *spec) + } + } + return specs +} + +// UpdateSetupCfg updates the version of a package in the install_requires section +// of a setup.cfg file. The existing operator is preserved. +func UpdateSetupCfg(path, packageName, newVersion string) error { + if err := validatePythonVersion(newVersion); err != nil { + return err + } + if err := validateManifestPath(path); err != nil { + return err + } + + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + norm := normalizePkgName(packageName) + lines := strings.Split(string(data), "\n") + found := false + versionRe := regexp.MustCompile(`([><=!~^]+)\s*([0-9][^\s,;#]*)`) + + for i, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + spec := parsePEP508(line) + if spec == nil || normalizePkgName(spec.Package) != norm { + continue + } + if spec.Specifier == "" { + lines[i] = strings.TrimRight(raw, " \t") + "==" + newVersion + } else { + replaced := false + newLine := versionRe.ReplaceAllStringFunc(raw, func(match string) string { + if replaced { + return match + } + replaced = true + mm := versionRe.FindStringSubmatch(match) + return mm[1] + newVersion + }) + lines[i] = newLine + } + found = true + } + + if !found { + return fmt.Errorf("%w: %s", ErrPackageNotFound, packageName) + } + return safeWriteFile(path, []byte(strings.Join(lines, "\n"))) +} + +// parseInlineInstallRequires handles inline install_requires = pkg>=1.0 lines. +// Returns true if the line was parsed as an inline install_requires. +func parseInlineInstallRequires(raw string, specs *[]VersionSpec) bool { + if !strings.Contains(strings.ToLower(raw), "install_requires") || !strings.Contains(raw, "=") { + return false + } + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 || strings.TrimSpace(strings.ToLower(parts[0])) != "install_requires" { + return false + } + val := strings.TrimSpace(parts[1]) + if val != "" { + if spec := parsePEP508(val); spec != nil { + *specs = append(*specs, *spec) + } + } + return true +} diff --git a/pkg/languages/python/testdata/hatch-pyproject/pyproject.toml b/pkg/languages/python/testdata/hatch-pyproject/pyproject.toml new file mode 100644 index 0000000..122e2ba --- /dev/null +++ b/pkg/languages/python/testdata/hatch-pyproject/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "example" +version = "1.0.0" +dependencies = [ + "requests>=2.28.0", + "cryptography>=39.0.1", + "urllib3>=1.26.0,<2.0", +] diff --git a/pkg/languages/python/testdata/maturin-project/pyproject.toml b/pkg/languages/python/testdata/maturin-project/pyproject.toml new file mode 100644 index 0000000..3d8a29f --- /dev/null +++ b/pkg/languages/python/testdata/maturin-project/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "example-rs" +version = "0.1.0" +dependencies = [ + "requests>=2.28.0", + "cryptography>=39.0.1", +] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/pkg/languages/python/testdata/pip-requirements/requirements.txt b/pkg/languages/python/testdata/pip-requirements/requirements.txt new file mode 100644 index 0000000..f0058aa --- /dev/null +++ b/pkg/languages/python/testdata/pip-requirements/requirements.txt @@ -0,0 +1,6 @@ +requests==2.28.2 +urllib3>=1.26.0,<2.0 +certifi~=2022.12.7 +# development only +pytest==7.2.0 +setuptools>=65.0 diff --git a/pkg/languages/python/testdata/poetry-pyproject/pyproject.toml b/pkg/languages/python/testdata/poetry-pyproject/pyproject.toml new file mode 100644 index 0000000..31a7381 --- /dev/null +++ b/pkg/languages/python/testdata/poetry-pyproject/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "example" +version = "1.0.0" +description = "Example Poetry project" + +[tool.poetry.dependencies] +python = "^3.9" +requests = "^2.28.0" +cryptography = ">=39.0.1" +urllib3 = "^1.26.0" diff --git a/pkg/languages/python/testdata/scikit-build-core/pyproject.toml b/pkg/languages/python/testdata/scikit-build-core/pyproject.toml new file mode 100644 index 0000000..8d29641 --- /dev/null +++ b/pkg/languages/python/testdata/scikit-build-core/pyproject.toml @@ -0,0 +1,11 @@ +[build-system] +requires = ["scikit-build-core>=0.3"] +build-backend = "scikit_build_core.build" + +[project] +name = "example-cpp" +version = "1.0.0" +dependencies = [ + "numpy>=1.24.0", + "requests>=2.28.0", +] diff --git a/pkg/languages/python/testdata/setup-cfg/setup.cfg b/pkg/languages/python/testdata/setup-cfg/setup.cfg new file mode 100644 index 0000000..33faf19 --- /dev/null +++ b/pkg/languages/python/testdata/setup-cfg/setup.cfg @@ -0,0 +1,14 @@ +[metadata] +name = example +version = 1.0.0 +description = Example setuptools project + +[options] +install_requires = + requests>=2.28.0 + cryptography>=39.0.1 + urllib3>=1.26.0,<2.0 + +[options.extras_require] +dev = + pytest>=7.0 diff --git a/pkg/languages/python/types.go b/pkg/languages/python/types.go new file mode 100644 index 0000000..b595a10 --- /dev/null +++ b/pkg/languages/python/types.go @@ -0,0 +1,188 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package python implements omnibump support for Python projects. +// Supports multiple build tools (pip, uv, hatch, poetry, pdm, maturin, +// scikit-build-core, setuptools) through a unified interface. +package python + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// Manifest file type constants. +const ( + // UVCommand is the uv package manager command. + UVCommand = "uv" + // PipCommand is the pip package manager command. + PipCommand = "pip" + + // ManifestPyprojectTOML is the pyproject.toml manifest type. + ManifestPyprojectTOML = "pyproject.toml" + // ManifestRequirementsTxt is the requirements.txt manifest type. + ManifestRequirementsTxt = "requirements.txt" + // ManifestSetupCfg is the setup.cfg manifest type. + ManifestSetupCfg = "setup.cfg" + // ManifestSetupPy is the setup.py manifest type. + ManifestSetupPy = "setup.py" + // ManifestPipfile is the Pipfile manifest type. + ManifestPipfile = "Pipfile" +) + +// BuildTool identifies the Python build system in use. +type BuildTool string + +const ( + // BuildToolPip indicates the pip build tool. + BuildToolPip BuildTool = "pip" + // BuildToolUV indicates the uv build tool. + BuildToolUV BuildTool = "uv" + // BuildToolHatch indicates the hatch build tool. + BuildToolHatch BuildTool = "hatch" + // BuildToolPoetry indicates the poetry build tool. + BuildToolPoetry BuildTool = "poetry" + // BuildToolPDM indicates the pdm build tool. + BuildToolPDM BuildTool = "pdm" + // BuildToolMaturin indicates the maturin build tool. + BuildToolMaturin BuildTool = "maturin" + // BuildToolSetuptools indicates the setuptools build tool. + BuildToolSetuptools BuildTool = "setuptools" + // BuildToolScikitBuild indicates the scikit-build tool. + BuildToolScikitBuild BuildTool = "scikit-build" + // BuildToolScikitBuildCore indicates the scikit-build-core tool. + BuildToolScikitBuildCore BuildTool = "scikit-build-core" + // BuildToolUnknown indicates an unknown build tool. + BuildToolUnknown BuildTool = "unknown" +) + +// ManifestInfo describes a detected Python manifest file. +type ManifestInfo struct { + // Path is the absolute path to the manifest file. + Path string + // Type is the manifest filename (e.g. "pyproject.toml", "requirements.txt"). + Type string + // BuildTool is the detected build backend. + BuildTool BuildTool +} + +// VersionSpec represents a single parsed dependency entry. +type VersionSpec struct { + // Package is the normalized package name. + Package string + // Specifier is the version operator(s), e.g. ">=", "==", "~=", "^". + Specifier string + // Version is the version string without the operator. + Version string + // RawLine is the original unparsed line from the manifest. + RawLine string +} + +// validateManifestPath checks that a path is safe for reading/writing. +// Paths must be absolute and exist. Call this before ReadFile/WriteFile operations. +func validateManifestPath(path string) error { + // Path must be absolute + if !filepath.IsAbs(path) { + return fmt.Errorf("%w: %s", ErrInvalidVersion, path) + } + // Path must have one of the known manifest filenames + base := filepath.Base(path) + switch base { + case ManifestPyprojectTOML, ManifestRequirementsTxt, ManifestSetupCfg, ManifestSetupPy, ManifestPipfile: + // Valid manifest filename + default: + return fmt.Errorf("%w: %s", ErrUnsupportedManifestType, base) + } + return nil +} + +// safeWriteFile writes to a manifest file after path validation. +func safeWriteFile(path string, data []byte) error { + if err := validateManifestPath(path); err != nil { + return err + } + // Verify path is still valid after Clean + cleanPath := filepath.Clean(path) + if !filepath.IsAbs(cleanPath) { + return fmt.Errorf("%w: %s", ErrInvalidPathAfterClean, cleanPath) + } + // Verify the base filename is still a manifest file + base := filepath.Base(cleanPath) + switch base { + case ManifestPyprojectTOML, ManifestRequirementsTxt, ManifestSetupCfg, ManifestSetupPy, ManifestPipfile: + // Path has been validated and cleaned, write to file + // #nosec G703 - Path is validated: absolute, cleaned, and filename verified against whitelist + return os.WriteFile(cleanPath, data, 0o600) + default: + return fmt.Errorf("%w: %s", ErrInvalidManifestFile, base) + } +} + +var ( + // ErrManifestNotFound is returned when no Python manifest is found. + ErrManifestNotFound = errors.New("no Python manifest file found") + + // ErrPackageNotFound is returned when the target package is not in the manifest. + ErrPackageNotFound = errors.New("package not found in manifest") + + // ErrInvalidVersion is returned when a version string fails validation. + ErrInvalidVersion = errors.New("invalid version string") + + // ErrVersionResolverUnavailable is returned when no registry can resolve a version. + ErrVersionResolverUnavailable = errors.New("version resolver unavailable") + + // ErrUnsupportedManifestType is returned for unsupported manifest types. + ErrUnsupportedManifestType = errors.New("unsupported manifest type") + + // ErrVenvDowngrade is returned when a version downgrade is attempted. + ErrVenvDowngrade = errors.New("downgrade rejected") + + // ErrVenvInvalidPinning is returned when venv mode requires == pinning. + ErrVenvInvalidPinning = errors.New("venv mode requires == pinning") + + // ErrVenvEmptyVersion is returned when a version is empty. + ErrVenvEmptyVersion = errors.New("empty version for package") + + // ErrVenvInvalidPackageName is returned when a package name is invalid (e.g., starts with '-'). + ErrVenvInvalidPackageName = errors.New("invalid package name") + + // ErrVenvInvalidVersionFormat is returned when a version format is invalid. + ErrVenvInvalidVersionFormat = errors.New("invalid version format") + + // ErrPipInstallFailed is returned when pip install fails. + ErrPipInstallFailed = errors.New("pip install failed") + + // ErrPipCheckFailed is returned when pip check fails. + ErrPipCheckFailed = errors.New("pip check failed") + + // ErrSetupPyReadOnly is returned when attempting to modify setup.py. + ErrSetupPyReadOnly = errors.New("setup.py is read-only: omnibump cannot safely update setup.py; migrate to setup.cfg or pyproject.toml") + + // ErrVersionNotFound is returned when no versions are found in registry. + ErrVersionNotFound = errors.New("no versions found in registry") + + // ErrInvalidVersionResponse is returned when version response is invalid. + ErrInvalidVersionResponse = errors.New("invalid version in response") + + // ErrHTTPNotFound is returned for 404 HTTP responses. + ErrHTTPNotFound = errors.New("not found") + + // ErrUnexpectedHTTPStatus is returned for unexpected HTTP status codes. + ErrUnexpectedHTTPStatus = errors.New("unexpected HTTP status") + + // ErrParseVersionFailed is returned when version parsing fails. + ErrParseVersionFailed = errors.New("failed to parse version number") + + // ErrNoVersionNumber is returned when a string has no leading digits. + ErrNoVersionNumber = errors.New("not a number") + + // ErrInvalidPathAfterClean is returned when a path is still invalid after filepath.Clean. + ErrInvalidPathAfterClean = errors.New("invalid path after clean") + + // ErrInvalidManifestFile is returned when the file is not a known manifest type. + ErrInvalidManifestFile = errors.New("invalid manifest file") +) diff --git a/pkg/languages/python/uv.go b/pkg/languages/python/uv.go new file mode 100644 index 0000000..1ab8f4e --- /dev/null +++ b/pkg/languages/python/uv.go @@ -0,0 +1,13 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +// uv projects declare dependencies in pyproject.toml (PEP 621 [project].dependencies). +// omnibump updates pyproject.toml directly. The user must re-run `uv lock` afterwards +// to regenerate the uv.lock file from the updated constraints. +// +// uv.lock detection is used to identify the build tool but the lockfile itself +// is not modified by omnibump. diff --git a/pkg/languages/python/venv.go b/pkg/languages/python/venv.go new file mode 100644 index 0000000..56f7b97 --- /dev/null +++ b/pkg/languages/python/venv.go @@ -0,0 +1,442 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/chainguard-dev/clog" + "github.com/chainguard-dev/omnibump/pkg/languages" +) + +// venvPkgNameRe validates PEP 503 normalized package names. +// Matches names like "requests", "python-dateutil", "my_pkg123" but rejects "-invalid", "pkg-" etc. +var venvPkgNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`) + +// updateVenv upgrades packages in a staged Python venv using uv pip install or the venv's pip. +// Validates that all versions use == pinning, rejects downgrades, and verifies the environment +// after upgrade using pip check. +func updateVenv(ctx context.Context, cfg *languages.UpdateConfig, venvPath string) error { + log := clog.FromContext(ctx) + + // Ensure venv path is absolute + absVenv, err := filepath.Abs(venvPath) + if err != nil { + return fmt.Errorf("invalid venv path: %w", err) + } + + // Verify venv exists + if _, err := os.Stat(absVenv); err != nil { + return fmt.Errorf("venv not found at %s: %w", absVenv, err) + } + + log.Infof("Using Python venv at: %s", absVenv) + + // Determine the tool to use (uv or pip) + var toolHint string + if t, ok := cfg.Options["tool"]; ok { + toolHint = t.(string) + } + + installer := selectVenvInstaller(absVenv, toolHint) + + log.Infof("Venv installer: %s", installer.name) + + // Validate and parse package specs + specs, err := parseAndValidateVenvSpecs(cfg.Dependencies) + if err != nil { + return err + } + + if len(specs) == 0 { + log.Infof("No packages to update") + return nil + } + + // Check current versions and reject downgrades + for _, spec := range specs { + current, err := getInstalledVersion(ctx, absVenv, installer, spec.Name) + if err != nil { + return err + } + + if current != "" { + // Compare versions: reject if spec is lower than current + if isVersionLower(spec.Version, current) { + return fmt.Errorf("%w: %s from %s to %s", ErrVenvDowngrade, spec.Name, current, spec.Version) + } + + if current == spec.Version { + log.Infof("%s: already at %s", spec.Name, current) + } else { + log.Infof("%s: %s -> %s", spec.Name, current, spec.Version) + } + } else { + log.Infof("%s: (new) -> %s", spec.Name, spec.Version) + } + } + + if cfg.DryRun { + log.Infof("[dry-run] would install: %v", specs) + return nil + } + + // Install the packages using the selected installer + if err := installer.install(ctx, absVenv, specs); err != nil { + return fmt.Errorf("pip install failed: %w", err) + } + + // Verify environment consistency + if err := installer.check(ctx, absVenv); err != nil { + return fmt.Errorf("pip check failed: %w", err) + } + + log.Infof("Updated packages and environment validation passed") + + return nil +} + +// validateVenv verifies that all packages in cfg.Dependencies are installed at the expected version. +func validateVenv(ctx context.Context, cfg *languages.UpdateConfig, venvPath string) error { + log := clog.FromContext(ctx) + + // Ensure venv path is absolute + absVenv, err := filepath.Abs(venvPath) + if err != nil { + return fmt.Errorf("invalid venv path: %w", err) + } + + // Verify venv exists + if _, err := os.Stat(absVenv); err != nil { + return fmt.Errorf("venv not found at %s: %w", absVenv, err) + } + + // Determine the tool to use + var toolHint string + if t, ok := cfg.Options["tool"]; ok { + toolHint = t.(string) + } + + installer := selectVenvInstaller(absVenv, toolHint) + + // Validate each dependency + for _, dep := range cfg.Dependencies { + current, err := getInstalledVersion(ctx, absVenv, installer, dep.Name) + if err != nil { + return err + } + + if current == "" { + return fmt.Errorf("validation: package %s not found in venv: %w", dep.Name, ErrPackageNotFound) + } + + if current != dep.Version { + log.Warnf("validation: %s expected %s but found %s", dep.Name, dep.Version, current) + return fmt.Errorf("validation failed: %s expected %s, got %s: %w", dep.Name, dep.Version, current, ErrInvalidVersion) + } + + log.Debugf("validation ok: %s == %s", dep.Name, current) + } + + return nil +} + +// venvSpecifier represents a package==version pair for venv installation. +type venvSpecifier struct { + Name string + Version string +} + +// parseAndValidateVenvSpecs validates that all dependencies use == pinning, have valid package names, +// and valid versions. Rejects package names that could be interpreted as command-line options. +func parseAndValidateVenvSpecs(deps []languages.Dependency) ([]venvSpecifier, error) { + specs := make([]venvSpecifier, 0, len(deps)) + + for _, dep := range deps { + // Reject package names starting with '-' to prevent option injection + if strings.HasPrefix(dep.Name, "-") { + return nil, fmt.Errorf("%w: %q (cannot start with '-')", ErrVenvInvalidPackageName, dep.Name) + } + + // Normalize and validate package name against PEP 503 rules + normName := normalizePkgName(dep.Name) + if !venvPkgNameRe.MatchString(normName) { + return nil, fmt.Errorf("%w: %q (must match PEP 503 format)", ErrVenvInvalidPackageName, dep.Name) + } + + // Validate == pinning + if !strings.HasPrefix(dep.Version, "==") { + return nil, fmt.Errorf("%w: %s@%s (use 'pkg==X.Y.Z')", ErrVenvInvalidPinning, dep.Name, dep.Version) + } + + version := strings.TrimPrefix(dep.Version, "==") + if version == "" { + return nil, fmt.Errorf("%w: %s", ErrVenvEmptyVersion, dep.Name) + } + + // Validate version format (basic check - no leading dashes or spaces) + if strings.HasPrefix(version, "-") || strings.Contains(version, " ") { + return nil, fmt.Errorf("%w: %q for package %s", ErrVenvInvalidVersionFormat, version, dep.Name) + } + + specs = append(specs, venvSpecifier{ + Name: dep.Name, + Version: version, + }) + } + + return specs, nil +} + +// venvInstaller abstracts over uv and pip for installing in a venv. +type venvInstaller struct { + name string + install func(ctx context.Context, venv string, specs []venvSpecifier) error + check func(ctx context.Context, venv string) error +} + +// selectVenvInstaller returns the appropriate installer (uv or pip) for the venv. +func selectVenvInstaller(_ string, toolHint string) *venvInstaller { + // If tool is explicitly specified as uv, use uv + if toolHint == UVCommand { + return &venvInstaller{ + name: UVCommand, + install: installWithUV, + check: checkWithUV, + } + } + + // If tool is explicitly specified as pip, use venv's pip + if toolHint == PipCommand { + return &venvInstaller{ + name: PipCommand, + install: installWithPip, + check: checkWithPip, + } + } + + // Auto-detect: if uv is in PATH, use it; otherwise use venv's pip + _, err := exec.LookPath("uv") + if err == nil { + return &venvInstaller{ + name: "uv", + install: installWithUV, + check: checkWithUV, + } + } + + return &venvInstaller{ + name: "pip", + install: installWithPip, + check: checkWithPip, + } +} + +// installWithUV installs packages using uv pip install. +func installWithUV(ctx context.Context, venv string, specs []venvSpecifier) error { + args := make([]string, 0, 6+len(specs)) + args = append(args, "pip", "install", "--upgrade", "--only-binary", ":all:", "--no-deps") + for _, spec := range specs { + args = append(args, fmt.Sprintf("%s==%s", spec.Name, spec.Version)) + } + + cmd := exec.CommandContext(ctx, UVCommand, args...) //nolint:gosec // args are constructed from validated package specs + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", ErrPipInstallFailed, stderr.String()) + } + + return nil +} + +// installWithPip installs packages using the venv's pip. +func installWithPip(ctx context.Context, venv string, specs []venvSpecifier) error { + pipBin := filepath.Join(venv, "bin", "pip") + // Validate pip exists before executing + if _, err := os.Stat(pipBin); err != nil { + return fmt.Errorf("pip not found in venv: %w", err) + } + + args := make([]string, 0, 3+len(specs)) + args = append(args, "install", "--upgrade", "--no-deps") + for _, spec := range specs { + args = append(args, fmt.Sprintf("%s==%s", spec.Name, spec.Version)) + } + + cmd := exec.CommandContext(ctx, pipBin, args...) //nolint:gosec // pipBin existence verified via os.Stat above + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", ErrPipInstallFailed, stderr.String()) + } + + return nil +} + +// checkWithUV verifies environment consistency using uv pip check. +func checkWithUV(ctx context.Context, venv string) error { + cmd := exec.CommandContext(ctx, UVCommand, "pip", "check") + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", ErrPipCheckFailed, stderr.String()) + } + + return nil +} + +// checkWithPip verifies environment consistency using the venv's pip check. +func checkWithPip(ctx context.Context, venv string) error { + pipBin := filepath.Join(venv, "bin", "pip") + // Validate pip exists before executing + if _, err := os.Stat(pipBin); err != nil { + return fmt.Errorf("pip not found in venv: %w", err) + } + + cmd := exec.CommandContext(ctx, pipBin, "check") //nolint:gosec // pipBin existence verified via os.Stat above + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", ErrPipCheckFailed, stderr.String()) + } + + return nil +} + +// getInstalledVersion returns the version of a package currently installed in the venv, +// or an empty string if the package is not installed. +func getInstalledVersion(ctx context.Context, venv string, installer *venvInstaller, pkgName string) (string, error) { + // Use pip list --format json to get installed packages + var cmd *exec.Cmd + if installer.name == "uv" { + cmd = exec.CommandContext(ctx, UVCommand, "pip", "list", "--format", "json") + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + } else { + pipBin := filepath.Join(venv, "bin", "pip") + // Validate pip exists before executing + if _, err := os.Stat(pipBin); err != nil { + return "", fmt.Errorf("pip not found in venv: %w", err) + } + cmd = exec.CommandContext(ctx, pipBin, "list", "--format", "json") //nolint:gosec // pipBin existence verified via os.Stat above + cmd.Env = append(os.Environ(), fmt.Sprintf("VIRTUAL_ENV=%s", venv)) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to list installed packages: %w: %s", err, stderr.String()) + } + + // Parse JSON output + var packages []struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal(stdout.Bytes(), &packages); err != nil { + return "", fmt.Errorf("failed to parse pip list output: %w", err) + } + + // Normalize package name for comparison (lowercase, replace hyphens/underscores) + normName := normalizePkgName(pkgName) + + for _, pkg := range packages { + if normalizePkgName(pkg.Name) == normName { + return pkg.Version, nil + } + } + + return "", nil +} + +// isVersionLower returns true if v1 < v2 by simple tuple comparison. +// Handles versions like "1.0.0", "2.3.4", etc. +// This is a simple comparison and doesn't handle pre-releases, post-releases, etc. +func isVersionLower(v1, v2 string) bool { + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + // Pad shorter version with zeros + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for i := 0; i < maxLen; i++ { + p1 := "0" + if i < len(parts1) { + p1 = parts1[i] + } + p2 := "0" + if i < len(parts2) { + p2 = parts2[i] + } + + // Simple numeric comparison (ignores pre-release suffixes) + n1, e1 := parseVersionNumber(p1) + n2, e2 := parseVersionNumber(p2) + + if e1 != nil || e2 != nil { + // Fall back to string comparison if parsing fails + if p1 < p2 { + return true + } else if p1 > p2 { + return false + } + continue + } + + if n1 < n2 { + return true + } else if n1 > n2 { + return false + } + } + + return false +} + +// parseVersionNumber extracts the leading numeric part of a version component. +func parseVersionNumber(s string) (int, error) { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + if i == 0 { + return 0, ErrNoVersionNumber + } + var num int + _, _ = fmt.Sscanf(s[:i], "%d", &num) + return num, nil + } + } + var num int + _, err := fmt.Sscanf(s, "%d", &num) + if err != nil { + return num, fmt.Errorf("%w: %s", ErrParseVersionFailed, s) + } + return num, nil +} diff --git a/pkg/languages/python/venv_test.go b/pkg/languages/python/venv_test.go new file mode 100644 index 0000000..8c732fb --- /dev/null +++ b/pkg/languages/python/venv_test.go @@ -0,0 +1,296 @@ +/* +Copyright 2026 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package python + +import ( + "testing" + + "github.com/chainguard-dev/omnibump/pkg/languages" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- parseAndValidateVenvSpecs --- + +func TestParseAndValidateVenvSpecs_Valid(t *testing.T) { + deps := []languages.Dependency{ + {Name: "cryptography", Version: "==46.0.6"}, + {Name: "pyjwt", Version: "==2.12.0"}, + {Name: "requests", Version: "==2.33.0"}, + } + + specs, err := parseAndValidateVenvSpecs(deps) + require.NoError(t, err) + require.Len(t, specs, 3) + + assert.Equal(t, "cryptography", specs[0].Name) + assert.Equal(t, "46.0.6", specs[0].Version) + assert.Equal(t, "pyjwt", specs[1].Name) + assert.Equal(t, "2.12.0", specs[1].Version) + assert.Equal(t, "requests", specs[2].Name) + assert.Equal(t, "2.33.0", specs[2].Version) +} + +func TestParseAndValidateVenvSpecs_RejectsNonEqualsPin(t *testing.T) { + deps := []languages.Dependency{ + {Name: "urllib3", Version: "~=2.6.0"}, + } + + _, err := parseAndValidateVenvSpecs(deps) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires == pinning") +} + +func TestParseAndValidateVenvSpecs_RejectsGreaterThanPin(t *testing.T) { + deps := []languages.Dependency{ + {Name: "authlib", Version: ">=1.3.1"}, + } + + _, err := parseAndValidateVenvSpecs(deps) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires == pinning") +} + +func TestParseAndValidateVenvSpecs_RejectsCaretPin(t *testing.T) { + deps := []languages.Dependency{ + {Name: "protobuf", Version: "^5.29.6"}, + } + + _, err := parseAndValidateVenvSpecs(deps) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires == pinning") +} + +func TestParseAndValidateVenvSpecs_EmptyVersion(t *testing.T) { + deps := []languages.Dependency{ + {Name: "requests", Version: "=="}, + } + + _, err := parseAndValidateVenvSpecs(deps) + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty version") +} + +func TestParseAndValidateVenvSpecs_EmptyDeps(t *testing.T) { + specs, err := parseAndValidateVenvSpecs([]languages.Dependency{}) + require.NoError(t, err) + assert.Len(t, specs, 0) +} + +// --- isVersionLower --- + +func TestIsVersionLower_SimpleComparison(t *testing.T) { + tests := []struct { + v1, v2 string + want bool + }{ + // v1 < v2 → true + {"1.0.0", "2.0.0", true}, + {"2.0.0", "2.1.0", true}, + {"2.1.0", "2.1.1", true}, + {"40.0.0", "46.0.6", true}, + // v1 >= v2 → false + {"2.0.0", "1.0.0", false}, + {"2.1.0", "2.0.0", false}, + {"2.1.1", "2.1.0", false}, + // v1 == v2 → false + {"1.0.0", "1.0.0", false}, + {"46.0.6", "46.0.6", false}, + // Version with different segment counts + {"1.0", "1.0.1", true}, + {"1.0.0", "1.0.1", true}, + {"1", "2", true}, + {"1.2", "1.2.0", false}, // effectively equal after padding + } + + for _, tt := range tests { + t.Run(tt.v1+"-vs-"+tt.v2, func(t *testing.T) { + got := isVersionLower(tt.v1, tt.v2) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsVersionLower_DowngradeDetection(t *testing.T) { + // Real CVE remediation scenario: current version vs target + // If target < current, it's a downgrade + tests := []struct { + current, target string + isDowngrade bool + }{ + {"46.0.6", "44.0.0", true}, // downgrade + {"46.0.6", "46.0.6", false}, // same version + {"46.0.6", "47.0.0", false}, // upgrade + {"2.12.0", "2.11.0", true}, // downgrade + {"2.12.0", "2.12.0", false}, // same + {"2.12.0", "2.13.0", false}, // upgrade + } + + for _, tt := range tests { + t.Run(tt.current+"-to-"+tt.target, func(t *testing.T) { + // isVersionLower(target, current) == true means downgrade + isDowngrade := isVersionLower(tt.target, tt.current) + assert.Equal(t, tt.isDowngrade, isDowngrade) + }) + } +} + +// --- selectVenvInstaller --- + +func TestSelectVenvInstaller_ExplicitUV(t *testing.T) { + installer := selectVenvInstaller("/tmp/venv", "uv") + assert.Equal(t, "uv", installer.name) +} + +func TestSelectVenvInstaller_ExplicitPip(t *testing.T) { + installer := selectVenvInstaller("/tmp/venv", "pip") + assert.Equal(t, "pip", installer.name) +} + +func TestSelectVenvInstaller_AutoDetect(t *testing.T) { + // Auto-detect with empty hint + // Will return uv if in PATH, else pip + installer := selectVenvInstaller("/tmp/venv", "") + // Should be one of these + assert.True(t, installer.name == "uv" || installer.name == "pip") +} + +// --- Spec parsing integration --- + +func TestParseAndValidateVenvSpecs_AirflowPattern(t *testing.T) { + // Real airflow-3 CVE remediation pattern + deps := []languages.Dependency{ + {Name: "cryptography", Version: "==46.0.6"}, // GHSA-r6ph-v2qm-q3c2 + {Name: "pyjwt", Version: "==2.12.0"}, // CVE-2026-32597 + {Name: "pyopenssl", Version: "==26.0.0"}, // CVE-2026-27459 + {Name: "pygments", Version: "==2.20.0"}, // GHSA-5239-wwwm-4pmq + {Name: "requests", Version: "==2.33.0"}, // GHSA-gc5v-m9x4-r6x2 + } + + specs, err := parseAndValidateVenvSpecs(deps) + require.NoError(t, err) + require.Len(t, specs, 5) + + // All should be validated and parsed + for i, spec := range specs { + assert.NotEmpty(t, spec.Name, "spec %d has empty name", i) + assert.NotEmpty(t, spec.Version, "spec %d has empty version", i) + } +} + +func TestParseAndValidateVenvSpecs_MixedValid(t *testing.T) { + // Multiple dependencies with various version formats + deps := []languages.Dependency{ + {Name: "aiohttp", Version: "==3.13.4"}, + {Name: "litellm", Version: "==1.83.0"}, + {Name: "ecdsa", Version: "==0.19.2"}, + } + + specs, err := parseAndValidateVenvSpecs(deps) + require.NoError(t, err) + require.Len(t, specs, 3) + + pkgMap := make(map[string]venvSpecifier) + for _, s := range specs { + pkgMap[s.Name] = s + } + + assert.Equal(t, "3.13.4", pkgMap["aiohttp"].Version) + assert.Equal(t, "1.83.0", pkgMap["litellm"].Version) + assert.Equal(t, "0.19.2", pkgMap["ecdsa"].Version) +} + +// --- parseVersionNumber --- + +func TestParseVersionNumber(t *testing.T) { + tests := []struct { + input string + want int + err bool + }{ + {"0", 0, false}, + {"1", 1, false}, + {"10", 10, false}, + {"123", 123, false}, + {"46", 46, false}, + {"46rc1", 46, false}, // pre-release suffix ignored + {"2a1", 2, false}, // alpha suffix ignored + {"0beta", 0, false}, // beta suffix ignored + {"", 0, true}, // empty string is error + {"a1", 0, true}, // no leading digits is error + {"rc1", 0, true}, // no leading digits is error + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseVersionNumber(tt.input) + if tt.err { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +// --- Version comparison edge cases --- + +func TestIsVersionLower_EdgeCases(t *testing.T) { + tests := []struct { + v1, v2 string + want bool + desc string + }{ + {"0.0.1", "0.0.2", true, "patch bump"}, + {"0.1.0", "0.2.0", true, "minor bump"}, + {"1.0.0", "2.0.0", true, "major bump"}, + {"2.0", "2.0.0", false, "equivalent after padding"}, + {"2", "2.0", false, "equivalent after padding"}, + {"1.0", "1.0.0", false, "equivalent (major.minor vs major.minor.patch)"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + got := isVersionLower(tt.v1, tt.v2) + assert.Equal(t, tt.want, got) + }) + } +} + +// --- Validation error messages --- + +func TestParseAndValidateVenvSpecs_ErrorMessages(t *testing.T) { + tests := []struct { + dep languages.Dependency + desc string + }{ + { + dep: languages.Dependency{Name: "pkg", Version: "~=1.0.0"}, + desc: "compatible-release pin", + }, + { + dep: languages.Dependency{Name: "pkg", Version: ">1.0.0"}, + desc: "greater-than pin", + }, + { + dep: languages.Dependency{Name: "pkg", Version: "<1.0.0"}, + desc: "less-than pin", + }, + { + dep: languages.Dependency{Name: "pkg", Version: "1.0.0"}, + desc: "no specifier", + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + _, err := parseAndValidateVenvSpecs([]languages.Dependency{tt.dep}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires ==") + }) + } +}