diff --git a/cmd/pythonBuild_test.go b/cmd/pythonBuild_test.go index 186bf408a8..9956187d43 100644 --- a/cmd/pythonBuild_test.go +++ b/cmd/pythonBuild_test.go @@ -129,3 +129,99 @@ func TestRunPythonBuild(t *testing.T) { assert.Equal(t, []string{"env", "--output-file", "bom-pip.xml", "--output-format", "XML", "--spec-version", "1.4"}, utils.ExecMockRunner.Calls[5].Params) }) } + +func TestRunPythonBuildWithToml(t *testing.T) { + cpe := pythonBuildCommonPipelineEnvironment{} + // utils := newPythonBuildTestsUtils() + + SetConfigOptions(ConfigCommandOptions{ + // OpenFile: utils.FilesMock.OpenFile, + OpenFile: config.OpenPiperFile, + }) + + t.Run("success - build", func(t *testing.T) { + config := pythonBuildOptions{ + VirtualEnvironmentName: "dummy", + } + utils := newPythonBuildTestsUtils() + utils.AddFile("pyproject.toml", []byte(minimalSetupPyFileContent)) + utils.AddDir("dummy") + telemetryData := telemetry.CustomData{} + + err := runPythonBuild(&config, &telemetryData, utils, &cpe) + assert.NoError(t, err) + // assert.Equal(t, 3, len(utils.ExecMockRunner.Calls)) + assert.Equal(t, "python3", utils.ExecMockRunner.Calls[0].Exec) + assert.Equal(t, []string{"-m", "venv", "dummy"}, utils.ExecMockRunner.Calls[0].Params) + }) + + t.Run("success - publishes binaries", func(t *testing.T) { + config := pythonBuildOptions{ + Publish: true, + TargetRepositoryURL: "https://my.target.repository.local", + TargetRepositoryUser: "user", + TargetRepositoryPassword: "password", + VirtualEnvironmentName: "dummy", + } + utils := newPythonBuildTestsUtils() + utils.AddFile("pyproject.toml", []byte(minimalSetupPyFileContent)) + utils.AddDir("dummy") + telemetryData := telemetry.CustomData{} + + err := runPythonBuild(&config, &telemetryData, utils, &cpe) + assert.NoError(t, err) + assert.Equal(t, "python3", utils.ExecMockRunner.Calls[0].Exec) + assert.Equal(t, []string{"-m", "venv", config.VirtualEnvironmentName}, utils.ExecMockRunner.Calls[0].Params) + assert.Equal(t, "bash", utils.ExecMockRunner.Calls[1].Exec) + assert.Equal(t, []string{"-c", "source " + filepath.Join("dummy", "bin", "activate")}, utils.ExecMockRunner.Calls[1].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[2].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "pip"}, utils.ExecMockRunner.Calls[2].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[3].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "."}, utils.ExecMockRunner.Calls[3].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[4].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "build"}, utils.ExecMockRunner.Calls[4].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[5].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "wheel"}, utils.ExecMockRunner.Calls[5].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "python"), utils.ExecMockRunner.Calls[6].Exec) + assert.Equal(t, []string{"-m", "build", "--no-isolation"}, utils.ExecMockRunner.Calls[6].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[7].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "twine"}, utils.ExecMockRunner.Calls[7].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "twine"), utils.ExecMockRunner.Calls[8].Exec) + assert.Equal(t, []string{"upload", "--username", config.TargetRepositoryUser, + "--password", config.TargetRepositoryPassword, "--repository-url", config.TargetRepositoryURL, + "--disable-progress-bar", "dist/*"}, utils.ExecMockRunner.Calls[8].Params) + }) + + t.Run("success - create BOM", func(t *testing.T) { + config := pythonBuildOptions{ + CreateBOM: true, + Publish: false, + VirtualEnvironmentName: "dummy", + } + utils := newPythonBuildTestsUtils() + utils.AddFile("pyproject.toml", []byte(minimalSetupPyFileContent)) + utils.AddDir("dummy") + telemetryData := telemetry.CustomData{} + + err := runPythonBuild(&config, &telemetryData, utils, &cpe) + assert.NoError(t, err) + assert.Equal(t, "python3", utils.ExecMockRunner.Calls[0].Exec) + assert.Equal(t, []string{"-m", "venv", config.VirtualEnvironmentName}, utils.ExecMockRunner.Calls[0].Params) + assert.Equal(t, "bash", utils.ExecMockRunner.Calls[1].Exec) + assert.Equal(t, []string{"-c", "source " + filepath.Join("dummy", "bin", "activate")}, utils.ExecMockRunner.Calls[1].Params) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "pip"}, utils.ExecMockRunner.Calls[2].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[2].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "."}, utils.ExecMockRunner.Calls[3].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[3].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "build"}, utils.ExecMockRunner.Calls[4].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[4].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "wheel"}, utils.ExecMockRunner.Calls[5].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[5].Exec) + assert.Equal(t, []string{"-m", "build", "--no-isolation"}, utils.ExecMockRunner.Calls[6].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "python"), utils.ExecMockRunner.Calls[6].Exec) + assert.Equal(t, []string{"install", "--upgrade", "--root-user-action=ignore", "cyclonedx-bom==6.1.1"}, utils.ExecMockRunner.Calls[7].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "pip"), utils.ExecMockRunner.Calls[7].Exec) + assert.Equal(t, []string{"env", "--output-file", "bom-pip.xml", "--output-format", "XML", "--spec-version", "1.4"}, utils.ExecMockRunner.Calls[8].Params) + assert.Equal(t, filepath.Join("dummy", "bin", "cyclonedx-py"), utils.ExecMockRunner.Calls[8].Exec) + }) +} diff --git a/pkg/versioning/toml.go b/pkg/versioning/toml.go new file mode 100644 index 0000000000..916bc9d06e --- /dev/null +++ b/pkg/versioning/toml.go @@ -0,0 +1,107 @@ +package versioning + +import ( + "fmt" + "strings" + + "github.com/BurntSushi/toml" +) + +const ( + TomlBuildDescriptor = "pyproject.toml" +) + +// Pip utility to interact with Python specific versioning +type Toml struct { + Pip + coordinates tomlCoordinates +} + +type tomlCoordinates struct { + Project struct { + Name string `toml:"name"` + Version string `toml:"version"` + } `toml:"project"` +} + +func (p *Toml) init() error { + var coordinates tomlCoordinates + + if !strings.Contains(p.Pip.path, TomlBuildDescriptor) { + return fmt.Errorf("file '%v' is not a %s", p.Pip.path, TomlBuildDescriptor) + } + + if err := p.Pip.init(); err != nil { + return err + } + + if _, err := toml.Decode(p.Pip.buildDescriptorContent, &coordinates); err != nil { + return err + } + p.coordinates = coordinates + return nil +} + +// GetName returns the name from the build descriptor +func (p *Toml) GetName() (string, error) { + if err := p.init(); err != nil { + return "", fmt.Errorf("failed to read file '%v': %w", p.Pip.path, err) + } + if len(p.coordinates.Project.Name) == 0 { + return "", fmt.Errorf("no name information found in file '%v'", p.Pip.path) + } + return p.coordinates.Project.Name, nil +} + +// // GetVersion returns the current version from the build descriptor +func (p *Toml) GetVersion() (string, error) { + if err := p.init(); err != nil { + return "", fmt.Errorf("failed to read file '%v': %w", p.Pip.path, err) + } + if len(p.coordinates.Project.Version) == 0 { + return "", fmt.Errorf("no version information found in file '%v'", p.Pip.path) + } + return p.coordinates.Project.Version, nil +} + +// SetVersion updates the version in the build descriptor +func (p *Toml) SetVersion(new string) error { + if current, err := p.GetVersion(); err != nil { + return err + } else { + // replace with single quotes + p.Pip.buildDescriptorContent = strings.ReplaceAll( + p.Pip.buildDescriptorContent, + fmt.Sprintf("version = '%v'", current), + fmt.Sprintf("version = '%v'", new)) + // replace with double quotes as well + p.Pip.buildDescriptorContent = strings.ReplaceAll( + p.Pip.buildDescriptorContent, + fmt.Sprintf("version = \"%v\"", current), + fmt.Sprintf("version = \"%v\"", new)) + err = p.Pip.writeFile(p.Pip.path, []byte(p.Pip.buildDescriptorContent), 0600) + if err != nil { + return fmt.Errorf("failed to write file '%v': %w", p.Pip.path, err) + } + return nil + } +} + +// GetCoordinates returns the build descriptor coordinates +func (p *Toml) GetCoordinates() (Coordinates, error) { + result := Coordinates{} + // get name + if name, err := p.GetName(); err != nil { + return result, fmt.Errorf("failed to retrieve coordinates: %w", err) + } else { + result.ArtifactID = name + } + // get version + if version, err := p.GetVersion(); err != nil { + return result, fmt.Errorf("failed to retrieve coordinates: %w", err) + } else { + result.Version = version + } + + return result, nil +} diff --git a/pkg/versioning/toml_test.go b/pkg/versioning/toml_test.go new file mode 100644 index 0000000000..b67c2944d3 --- /dev/null +++ b/pkg/versioning/toml_test.go @@ -0,0 +1,230 @@ +//go:build unit +// +build unit + +package versioning + +import ( + "fmt" + "testing" + + piperMock "github.com/SAP/jenkins-library/pkg/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const ( + invalidToml = `[project]` + sampleToml = `[project] +name = "simple-python" +version = "1.2.3" +` + missingVersionToml = `[project] +name = "simple-python" +` + largeSampleToml = `[project] +name = "sampleproject" +version = "4.0.0" +description = "A sample Python project" +license = { file = "LICENSE.txt" } + +authors = [{ name = "A. Random Developer", email = "author@example.com" }] +requires-python = ">=3.9" +readme = "README.md" + + +maintainers = [{ name = "A. Great Maintainer", email = "maintainer@example.com" }] +keywords = [ + "sample", + "setuptools", + "development", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", +] +dependencies = ["peppercorn"] + +[project.optional-dependencies] +dev = ["check-manifest"] +test = ["coverage"] + +[project.urls] +Homepage = "https://github.com/pypa/sampleproject" +"Bug Reports" = "https://github.com/pypa/sampleproject/issues" +Funding = "https://donate.pypi.org" +"Say Thanks!" = "http://saythanks.io/to/example" +Source = "https://github.com/pypa/sampleproject/" + +[project.scripts] +sample = "sample:main" + +[build-system] +# A list of packages that are needed to build your package: +requires = ["setuptools"] # REQUIRED if [build-system] table is used +# The name of the Python object that frontends will use to perform the build: +build-backend = "setuptools.build_meta" # If not defined, then legacy behavior can happen. + +[tool.uv] +package = false + +[tool.setuptools] +# If there are data files included in your packages that need to be +# installed, specify them here. +package-data = { "sample" = ["*.dat"] } +` +) + +func TestTomlSetVersion(t *testing.T) { + t.Parallel() + t.Run("success case - large pyproject.toml", func(t *testing.T) { + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile("pyproject.toml", []byte(largeSampleToml)) + + toml := Toml{ + Pip: Pip{ + path: "pyproject.toml", + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.NoError(t, err) + assert.Equal(t, "sampleproject", coordinates.ArtifactID) + assert.Equal(t, "4.0.0", coordinates.Version) + + // test SetVersion + err = toml.SetVersion("5.0.0") + assert.NoError(t, err) + coordinates, err = toml.GetCoordinates() + assert.NoError(t, err) + assert.Equal(t, "sampleproject", coordinates.ArtifactID) + assert.Equal(t, "5.0.0", coordinates.Version) + }) +} + +func TestTomlGetCoordinates(t *testing.T) { + t.Parallel() + t.Run("success case - pyproject.toml", func(t *testing.T) { + filename := TomlBuildDescriptor + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile(filename, []byte(sampleToml)) + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.NoError(t, err) + assert.Equal(t, "simple-python", coordinates.ArtifactID) + assert.Equal(t, "1.2.3", coordinates.Version) + }) + t.Run("fail - invalid pyproject.toml", func(t *testing.T) { + filename := TomlBuildDescriptor + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile(filename, []byte(invalidToml)) + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.ErrorContains(t, err, fmt.Sprintf("no name information found in file '%s'", filename)) + assert.Equal(t, "", coordinates.ArtifactID) + assert.Equal(t, "", coordinates.Version) + }) + t.Run("fail - invalid pyproject.toml", func(t *testing.T) { + filename := TomlBuildDescriptor + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile(filename, []byte(missingVersionToml)) + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.ErrorContains(t, err, fmt.Sprintf("no version information found in file '%s'", filename)) + assert.Equal(t, "simple-python", coordinates.ArtifactID) + assert.Equal(t, "", coordinates.Version) + }) + t.Run("fail - empty pyproject.toml", func(t *testing.T) { + filename := TomlBuildDescriptor + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile(filename, []byte("")) + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.ErrorContains(t, err, fmt.Sprintf("no name information found in file '%s'", filename)) + assert.Equal(t, "", coordinates.ArtifactID) + assert.Equal(t, "", coordinates.Version) + }) + t.Run("fail - no pyproject.toml", func(t *testing.T) { + filename := mock.Anything + fileUtils := piperMock.FilesMock{} + fileUtils.AddFile(filename, []byte("")) + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.ErrorContains(t, err, fmt.Sprintf("file '%s' is not a pyproject.toml", filename)) + assert.Equal(t, "", coordinates.ArtifactID) + assert.Equal(t, "", coordinates.Version) + }) + t.Run("fail - missing pyproject.toml", func(t *testing.T) { + filename := TomlBuildDescriptor + fileUtils := piperMock.FilesMock{} + + toml := Toml{ + Pip: Pip{ + path: filename, + fileExists: fileUtils.FileExists, + readFile: fileUtils.FileRead, + writeFile: fileUtils.FileWrite, + }, + } + + coordinates, err := toml.GetCoordinates() + assert.ErrorContains(t, err, fmt.Sprintf("failed to read file '%s'", filename)) + assert.Equal(t, "", coordinates.ArtifactID) + assert.Equal(t, "", coordinates.Version) + }) +} diff --git a/pkg/versioning/versioning.go b/pkg/versioning/versioning.go index 9ae5dfd79b..a4958d573d 100644 --- a/pkg/versioning/versioning.go +++ b/pkg/versioning/versioning.go @@ -173,14 +173,24 @@ func GetArtifact(buildTool, buildDescriptorFilePath string, opts *Options, utils case "pip": if len(buildDescriptorFilePath) == 0 { var err error - buildDescriptorFilePath, err = searchDescriptor([]string{"setup.py", "version.txt", "VERSION"}, fileExists) + buildDescriptorFilePath, err = searchDescriptor([]string{TomlBuildDescriptor, "setup.py", "version.txt", "VERSION"}, fileExists) if err != nil { return artifact, err } } - artifact = &Pip{ - path: buildDescriptorFilePath, - fileExists: fileExists, + switch buildDescriptorFilePath { + case TomlBuildDescriptor: + artifact = &Toml{ + Pip: Pip{ + path: buildDescriptorFilePath, + fileExists: fileExists, + }, + } + default: + artifact = &Pip{ + path: buildDescriptorFilePath, + fileExists: fileExists, + } } case "sbt": if len(buildDescriptorFilePath) == 0 { diff --git a/pkg/versioning/versioning_test.go b/pkg/versioning/versioning_test.go index c18867efdf..ead099c533 100644 --- a/pkg/versioning/versioning_test.go +++ b/pkg/versioning/versioning_test.go @@ -217,8 +217,10 @@ func TestGetArtifact(t *testing.T) { }) t.Run("pip", func(t *testing.T) { - fileExists = func(string) (bool, error) { return true, nil } - pip, err := GetArtifact("pip", "", &Options{}, nil) + utils := newVersioningMockUtils() + utils.FilesMock.AddFile("setup.py", []byte("")) + fileExists = utils.FilesMock.FileExists + pip, err := GetArtifact("pip", "", &Options{}, utils) assert.NoError(t, err) @@ -232,7 +234,7 @@ func TestGetArtifact(t *testing.T) { fileExists = func(string) (bool, error) { return false, nil } _, err := GetArtifact("pip", "", &Options{}, nil) - assert.EqualError(t, err, "no build descriptor available, supported: [setup.py version.txt VERSION]") + assert.EqualError(t, err, "no build descriptor available, supported: [pyproject.toml setup.py version.txt VERSION]") }) t.Run("sbt", func(t *testing.T) {