feat(pyproject): release v1.7.0 — Python pyproject.toml version bump
Add internal/pyproject package with ReadVersion and WriteVersion for pyproject.toml files. Reads [project].version (PEP 621) first, then falls back to [tool.poetry].version. Section boundaries are detected via TOML's rule that headers always start at the beginning of a line (\n[ pattern), so inline arrays with [ characters don't interfere. Config follows the established multi-value pattern: - python.pyproject_toml: single path (opt-in, no default) - python.pyproject_tomls: list for monorepos (overrides single) - --pyproject flag overrides pyproject_toml and clears pyproject_tomls 100% per-package statement coverage maintained across all 14 packages; FuzzReadVersion and FuzzWriteVersion added per fuzzing guidelines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+49
-7
@@ -20,6 +20,7 @@ import (
|
||||
"git.k3nny.fr/releaser/internal/glclient"
|
||||
"git.k3nny.fr/releaser/internal/gradle"
|
||||
"git.k3nny.fr/releaser/internal/maven"
|
||||
"git.k3nny.fr/releaser/internal/pyproject"
|
||||
"git.k3nny.fr/releaser/internal/node"
|
||||
"git.k3nny.fr/releaser/internal/notes"
|
||||
semver "git.k3nny.fr/releaser/internal/version"
|
||||
@@ -90,6 +91,17 @@ gradle:
|
||||
# - "module-a/build.gradle"
|
||||
# - "module-b/build.gradle"
|
||||
|
||||
python:
|
||||
# Single pyproject.toml path (opt-in — no default).
|
||||
# Reads [project].version (PEP 621) first, then [tool.poetry].version.
|
||||
# pyproject_toml: "pyproject.toml"
|
||||
|
||||
# Multiple pyproject.toml paths for monorepos (overrides pyproject_toml).
|
||||
# pyproject_tomls:
|
||||
# - "pyproject.toml"
|
||||
# - "packages/cli/pyproject.toml"
|
||||
# - "packages/lib/pyproject.toml"
|
||||
|
||||
gitlab:
|
||||
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
|
||||
# url: "https://gitlab.example.com"
|
||||
@@ -161,8 +173,9 @@ func newRootCmd() *cobra.Command {
|
||||
branchOverride string
|
||||
repoPath string
|
||||
pomOverride string
|
||||
gradleOverride string
|
||||
changelogFile string
|
||||
gradleOverride string
|
||||
pyprojectOverride string
|
||||
changelogFile string
|
||||
tagPrefixFlag string
|
||||
tagPrefixSet bool
|
||||
patternFlag string
|
||||
@@ -184,8 +197,9 @@ func newRootCmd() *cobra.Command {
|
||||
repoPath: repoPath,
|
||||
branchOverride: branchOverride,
|
||||
pomOverride: pomOverride,
|
||||
gradleOverride: gradleOverride,
|
||||
changelogFile: changelogFile,
|
||||
gradleOverride: gradleOverride,
|
||||
pyprojectOverride: pyprojectOverride,
|
||||
changelogFile: changelogFile,
|
||||
tagPrefixFlag: tagPrefixFlag,
|
||||
tagPrefixSet: tagPrefixSet,
|
||||
patternFlag: patternFlag,
|
||||
@@ -211,6 +225,7 @@ func newRootCmd() *cobra.Command {
|
||||
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
|
||||
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
|
||||
root.Flags().StringVar(&gradleOverride, "gradle", "", "override gradle.build_file from config")
|
||||
root.Flags().StringVar(&pyprojectOverride, "pyproject", "", "override python.pyproject_toml from config")
|
||||
root.Flags().StringVar(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root")
|
||||
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
|
||||
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
|
||||
@@ -235,9 +250,10 @@ type options struct {
|
||||
repoPath string
|
||||
branchOverride string
|
||||
pomOverride string
|
||||
gradleOverride string
|
||||
changelogFile string
|
||||
tagPrefixFlag string
|
||||
gradleOverride string
|
||||
pyprojectOverride string
|
||||
changelogFile string
|
||||
tagPrefixFlag string
|
||||
tagPrefixSet bool
|
||||
patternFlag string
|
||||
patternSet bool
|
||||
@@ -296,6 +312,13 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
|
||||
}
|
||||
return strings.Join(paths, ", ")
|
||||
}()},
|
||||
{"python.paths", func() string {
|
||||
paths := cfg.Python.EffectivePaths()
|
||||
if len(paths) == 0 {
|
||||
return "(not configured)"
|
||||
}
|
||||
return strings.Join(paths, ", ")
|
||||
}()},
|
||||
{"gitlab.url", cfg.GitLab.URL},
|
||||
{"gitlab.token", func() string {
|
||||
if cfg.GitLab.Token != "" {
|
||||
@@ -388,6 +411,11 @@ func run(o options) error {
|
||||
cfg.Gradle.BuildFiles = nil
|
||||
src["gradle.build_files"] = "flag: --gradle"
|
||||
}
|
||||
if o.pyprojectOverride != "" {
|
||||
cfg.Python.PyprojectTOML = o.pyprojectOverride
|
||||
cfg.Python.PyprojectTOMLs = nil
|
||||
src["python.pyproject_tomls"] = "flag: --pyproject"
|
||||
}
|
||||
if o.patternSet {
|
||||
cfg.Git.BranchPattern = o.patternFlag
|
||||
src["git.branch_pattern"] = "flag: --branch-pattern"
|
||||
@@ -594,6 +622,20 @@ func run(o options) error {
|
||||
filesToCommit = append(filesToCommit, relGradlePath)
|
||||
}
|
||||
|
||||
// pyproject.toml (opt-in via python.pyproject_toml / python.pyproject_tomls)
|
||||
for _, relPyprojectPath := range cfg.Python.EffectivePaths() {
|
||||
pyprojectPath := filepath.Join(absRepo, relPyprojectPath)
|
||||
currentPyVersion, err := pyproject.ReadVersion(pyprojectPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pyproject version: %w", err)
|
||||
}
|
||||
if err := pyproject.WriteVersion(pyprojectPath, currentPyVersion, nextVersion); err != nil {
|
||||
return fmt.Errorf("update pyproject version: %w", err)
|
||||
}
|
||||
logDone("%s: %s → %s", relPyprojectPath, currentPyVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, relPyprojectPath)
|
||||
}
|
||||
|
||||
// CHANGELOG.md
|
||||
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
|
||||
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
|
||||
|
||||
@@ -1101,6 +1101,7 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
|
||||
},
|
||||
Node: config.NodeConfig{PackageJSON: "package.json"},
|
||||
Gradle: config.GradleConfig{BuildFile: "build.gradle"},
|
||||
Python: config.PythonConfig{PyprojectTOML: "pyproject.toml"},
|
||||
}
|
||||
src := config.Sources{}
|
||||
|
||||
@@ -1122,6 +1123,9 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
|
||||
if !strings.Contains(output, "build.gradle") {
|
||||
t.Error("expected 'build.gradle' in output for gradle.paths")
|
||||
}
|
||||
if !strings.Contains(output, "pyproject.toml") {
|
||||
t.Error("expected 'pyproject.toml' in output for python.paths")
|
||||
}
|
||||
}
|
||||
|
||||
// ── node package.json handling ────────────────────────────────────────────────
|
||||
@@ -1321,3 +1325,143 @@ func TestRunGradleWriteVersionFails(t *testing.T) {
|
||||
t.Fatal("expected error when build.gradle is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
// ── pyproject.toml handling ───────────────────────────────────────────────────
|
||||
|
||||
func writePyprojectFile(t *testing.T, dir, ver string) {
|
||||
t.Helper()
|
||||
content := fmt.Sprintf("[project]\nname = \"my-app\"\nversion = \"%s\"\n", ver)
|
||||
if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPyprojectVersionBump(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePyprojectFile(t, dir, "0.0.0")
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("python:\n pyproject_toml: \"pyproject.toml\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir); err != nil {
|
||||
t.Fatalf("pyproject version bump: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "pyproject.toml"))
|
||||
if !strings.Contains(string(data), `version = "1.2.0"`) {
|
||||
t.Errorf("expected version 1.2.0 in pyproject.toml, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPyprojectPoetryVersionBump(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "[tool.poetry]\nname = \"my-app\"\nversion = \"0.0.0\"\n"
|
||||
os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(content), 0644)
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("python:\n pyproject_toml: \"pyproject.toml\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir); err != nil {
|
||||
t.Fatalf("poetry version bump: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "pyproject.toml"))
|
||||
if !strings.Contains(string(data), `version = "1.2.0"`) {
|
||||
t.Errorf("expected version 1.2.0 in pyproject.toml, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPyprojectOverrideFlag(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "[project]\nversion = \"0.0.0\"\n"
|
||||
os.WriteFile(filepath.Join(dir, "sub", "pyproject.toml"), []byte(content), 0644)
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--pyproject", "sub/pyproject.toml"); err != nil {
|
||||
t.Fatalf("--pyproject flag: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "sub", "pyproject.toml"))
|
||||
if !strings.Contains(string(data), `version = "1.2.0"`) {
|
||||
t.Errorf("expected version 1.2.0, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPyprojectReadVersionFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// pyproject.toml with no version field
|
||||
os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte("[build-system]\nrequires=[]\n"), 0644)
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("python:\n pyproject_toml: \"pyproject.toml\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil {
|
||||
t.Fatal("expected error when pyproject.toml has no version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPyprojectWriteVersionFails(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("skipping: chmod restrictions do not apply when running as root")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePyprojectFile(t, dir, "0.0.0")
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("python:\n pyproject_toml: \"pyproject.toml\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
os.Chmod(filepath.Join(dir, "pyproject.toml"), 0444)
|
||||
defer os.Chmod(filepath.Join(dir, "pyproject.toml"), 0644)
|
||||
|
||||
if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil {
|
||||
t.Fatal("expected error when pyproject.toml is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user