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:
2026-07-12 00:22:49 +02:00
parent 7483fd4194
commit 72df0f86f5
14 changed files with 637 additions and 14 deletions
+10
View File
@@ -50,6 +50,16 @@ git:
# - "packages/frontend/package.json" # - "packages/frontend/package.json"
# - "packages/backend/package.json" # - "packages/backend/package.json"
# 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"
# gradle: # gradle:
# Single build.gradle or build.gradle.kts path (opt-in — no default). # Single build.gradle or build.gradle.kts path (opt-in — no default).
# Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported. # Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported.
+9
View File
@@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.7.0] - 2026-07-12
### Added
- **Python `pyproject.toml` support** — new `internal/pyproject` package; reads `[project].version` (PEP 621) first, then `[tool.poetry].version` (Poetry); original formatting preserved on write; `regexp.QuoteMeta` ensures safety with dot-containing version strings
- **`python.pyproject_toml` / `python.pyproject_tomls` config** — opt-in, no default; `pyproject_tomls` list overrides `pyproject_toml` for monorepos; follows the established multi-value pattern (Maven, Node, Gradle)
- **`--pyproject <path>` flag** — overrides `python.pyproject_toml` and clears `pyproject_tomls`; shown in verbose config table as `python.paths`
- **`FuzzReadVersion` / `FuzzWriteVersion`** in `internal/pyproject` — 100% per-package statement coverage maintained across all 14 packages
## [1.6.3] - 2026-07-11 ## [1.6.3] - 2026-07-11
### Added ### Added
+1
View File
@@ -62,6 +62,7 @@ All seed cases must pass. The table below is authoritative — keep it in sync w
| `internal/changelog` | `FuzzUpdate` | rewrites arbitrary existing file content | | `internal/changelog` | `FuzzUpdate` | rewrites arbitrary existing file content |
| `internal/commits` | `FuzzParse` | parses arbitrary commit message strings | | `internal/commits` | `FuzzParse` | parses arbitrary commit message strings |
| `internal/gradle` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary Gradle build file content | | `internal/gradle` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary Gradle build file content |
| `internal/pyproject` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary pyproject.toml content |
| `internal/glclient` | `FuzzEncodeProjectPath` | encodes arbitrary project path strings | | `internal/glclient` | `FuzzEncodeProjectPath` | encodes arbitrary project path strings |
| `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content | | `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content |
| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content | | `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content |
+7 -1
View File
@@ -2,7 +2,7 @@
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128"> <img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
![release](https://img.shields.io/badge/release-v1.6.3-blue.svg) ![release](https://img.shields.io/badge/release-v1.7.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -125,6 +125,12 @@ gradle: # opt-in — no default; om
# - "build.gradle" # - "build.gradle"
# - "module-a/build.gradle" # - "module-a/build.gradle"
python: # opt-in — no default; omit to skip
# pyproject_toml: "pyproject.toml" # PEP 621 [project] or [tool.poetry]
# pyproject_tomls: # monorepo: list overrides pyproject_toml
# - "pyproject.toml"
# - "packages/cli/pyproject.toml"
gitlab: gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # env GITLAB_TOKEN (never commit this) token: "" # env GITLAB_TOKEN (never commit this)
+1 -1
View File
@@ -86,5 +86,5 @@
## Future / backlog ## Future / backlog
- ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag) - ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag)
- Python `pyproject.toml` version bump (`[project].version` and `[tool.poetry].version`; single and multi-path like Maven's `pom_paths`) - ~~Python `pyproject.toml` version bump (`[project].version` and `[tool.poetry].version`; single and multi-path like Maven's `pom_paths`)~~ — ✓ shipped v1.7.0 (`internal/pyproject`; PEP 621 + Poetry; `python.pyproject_tomls`; `--pyproject` flag)
- Slack / Teams notification on release - Slack / Teams notification on release
+49 -7
View File
@@ -20,6 +20,7 @@ import (
"git.k3nny.fr/releaser/internal/glclient" "git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/gradle" "git.k3nny.fr/releaser/internal/gradle"
"git.k3nny.fr/releaser/internal/maven" "git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/pyproject"
"git.k3nny.fr/releaser/internal/node" "git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes" "git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version" semver "git.k3nny.fr/releaser/internal/version"
@@ -90,6 +91,17 @@ gradle:
# - "module-a/build.gradle" # - "module-a/build.gradle"
# - "module-b/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:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com" # url: "https://gitlab.example.com"
@@ -161,8 +173,9 @@ func newRootCmd() *cobra.Command {
branchOverride string branchOverride string
repoPath string repoPath string
pomOverride string pomOverride string
gradleOverride string gradleOverride string
changelogFile string pyprojectOverride string
changelogFile string
tagPrefixFlag string tagPrefixFlag string
tagPrefixSet bool tagPrefixSet bool
patternFlag string patternFlag string
@@ -184,8 +197,9 @@ func newRootCmd() *cobra.Command {
repoPath: repoPath, repoPath: repoPath,
branchOverride: branchOverride, branchOverride: branchOverride,
pomOverride: pomOverride, pomOverride: pomOverride,
gradleOverride: gradleOverride, gradleOverride: gradleOverride,
changelogFile: changelogFile, pyprojectOverride: pyprojectOverride,
changelogFile: changelogFile,
tagPrefixFlag: tagPrefixFlag, tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet, tagPrefixSet: tagPrefixSet,
patternFlag: patternFlag, patternFlag: patternFlag,
@@ -211,6 +225,7 @@ func newRootCmd() *cobra.Command {
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository") root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config") 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(&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(&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(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
@@ -235,9 +250,10 @@ type options struct {
repoPath string repoPath string
branchOverride string branchOverride string
pomOverride string pomOverride string
gradleOverride string gradleOverride string
changelogFile string pyprojectOverride string
tagPrefixFlag string changelogFile string
tagPrefixFlag string
tagPrefixSet bool tagPrefixSet bool
patternFlag string patternFlag string
patternSet bool patternSet bool
@@ -296,6 +312,13 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
} }
return strings.Join(paths, ", ") 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.url", cfg.GitLab.URL},
{"gitlab.token", func() string { {"gitlab.token", func() string {
if cfg.GitLab.Token != "" { if cfg.GitLab.Token != "" {
@@ -388,6 +411,11 @@ func run(o options) error {
cfg.Gradle.BuildFiles = nil cfg.Gradle.BuildFiles = nil
src["gradle.build_files"] = "flag: --gradle" 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 { if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag cfg.Git.BranchPattern = o.patternFlag
src["git.branch_pattern"] = "flag: --branch-pattern" src["git.branch_pattern"] = "flag: --branch-pattern"
@@ -594,6 +622,20 @@ func run(o options) error {
filesToCommit = append(filesToCommit, relGradlePath) 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 // CHANGELOG.md
changelogAbsPath := filepath.Join(absRepo, o.changelogFile) changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
+144
View File
@@ -1101,6 +1101,7 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
}, },
Node: config.NodeConfig{PackageJSON: "package.json"}, Node: config.NodeConfig{PackageJSON: "package.json"},
Gradle: config.GradleConfig{BuildFile: "build.gradle"}, Gradle: config.GradleConfig{BuildFile: "build.gradle"},
Python: config.PythonConfig{PyprojectTOML: "pyproject.toml"},
} }
src := config.Sources{} src := config.Sources{}
@@ -1122,6 +1123,9 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
if !strings.Contains(output, "build.gradle") { if !strings.Contains(output, "build.gradle") {
t.Error("expected 'build.gradle' in output for gradle.paths") 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 ──────────────────────────────────────────────── // ── node package.json handling ────────────────────────────────────────────────
@@ -1321,3 +1325,143 @@ func TestRunGradleWriteVersionFails(t *testing.T) {
t.Fatal("expected error when build.gradle is read-only") 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")
}
}
+6
View File
@@ -3,6 +3,12 @@ title: Changelog
weight: 50 weight: 50
--- ---
## v1.7.0 — 2026-07-12
### Added
- **Python `pyproject.toml` support** — opt-in via `python.pyproject_toml` (single) or `python.pyproject_tomls` (list); reads `[project].version` (PEP 621) first, then `[tool.poetry].version`; original formatting preserved; `--pyproject <path>` CLI flag for one-off overrides
## v1.6.0 — 2026-07-11 ## v1.6.0 — 2026-07-11
### Added ### Added
+29
View File
@@ -53,6 +53,14 @@ gradle: # opt-in — omit section t
# - "build.gradle" # - "build.gradle"
# - "module-a/build.gradle" # - "module-a/build.gradle"
python: # opt-in — omit section to skip
# pyproject_toml: "pyproject.toml" # PEP 621 [project].version or [tool.poetry].version
# Monorepo: list overrides pyproject_toml.
# pyproject_tomls:
# - "pyproject.toml"
# - "packages/cli/pyproject.toml"
gitlab: gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # prefer env GITLAB_TOKEN token: "" # prefer env GITLAB_TOKEN
@@ -128,6 +136,27 @@ node:
- "packages/backend/package.json" - "packages/backend/package.json"
``` ```
## Python support
The `python` section is opt-in — if omitted, no `pyproject.toml` is touched. `releaser` reads `[project].version` (PEP 621) first; if not found it falls back to `[tool.poetry].version`. The original file formatting is preserved on write.
```yaml
python:
pyproject_toml: "pyproject.toml"
```
Use `pyproject_tomls` for monorepos:
```yaml
python:
pyproject_tomls:
- "pyproject.toml"
- "packages/cli/pyproject.toml"
- "packages/lib/pyproject.toml"
```
The `--pyproject <path>` CLI flag sets a single path and clears `pyproject_tomls`.
## Gradle support ## Gradle support
The `gradle` section is opt-in — if omitted, no build file is touched. Both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`) are supported; the original quote style is preserved on write. The `gradle` section is opt-in — if omitted, no build file is touched. Both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`) are supported; the original quote style is preserved on write.
+1
View File
@@ -40,6 +40,7 @@ releaser --verbose --dry-run
| `--tag-prefix <prefix>` | `""` | Prefix for version tags (e.g. `v``v1.2.3`) | | `--tag-prefix <prefix>` | `""` | Prefix for version tags (e.g. `v``v1.2.3`) |
| `--pom <path>` | `pom.xml` | Path to pom.xml relative to repo root | | `--pom <path>` | `pom.xml` | Path to pom.xml relative to repo root |
| `--gradle <path>` | — | Override `gradle.build_file` from config | | `--gradle <path>` | — | Override `gradle.build_file` from config |
| `--pyproject <path>` | — | Override `python.pyproject_toml` from config |
| `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file | | `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file |
| `--release-env-file <path>` | `release.env` | Path for dotenv artifact; pass `""` to disable | | `--release-env-file <path>` | `release.env` | Path for dotenv artifact; pass `""` to disable |
| `--no-commit` | false | Update version files but stop before committing | | `--no-commit` | false | Update version files but stop before committing |
+31 -5
View File
@@ -17,8 +17,9 @@ type Config struct {
Git GitConfig `yaml:"git"` Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"` Maven MavenConfig `yaml:"maven"`
Node NodeConfig `yaml:"node"` Node NodeConfig `yaml:"node"`
Gradle GradleConfig `yaml:"gradle"` Gradle GradleConfig `yaml:"gradle"`
GitLab GitLabConfig `yaml:"gitlab"` Python PythonConfig `yaml:"python"`
GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"` GitHub GitHubConfig `yaml:"github"`
} }
@@ -91,6 +92,23 @@ func (g GradleConfig) EffectiveBuildFiles() []string {
return nil return nil
} }
type PythonConfig struct {
PyprojectTOML string `yaml:"pyproject_toml"` // single path (opt-in, no default)
PyprojectTOMLs []string `yaml:"pyproject_tomls"` // multiple paths; overrides PyprojectTOML
}
// EffectivePaths returns the list of pyproject.toml paths to process.
// Returns nil when no python paths are configured (python processing is opt-in).
func (p PythonConfig) EffectivePaths() []string {
if len(p.PyprojectTOMLs) > 0 {
return p.PyprojectTOMLs
}
if p.PyprojectTOML != "" {
return []string{p.PyprojectTOML}
}
return nil
}
type GitLabConfig struct { type GitLabConfig struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Token string `yaml:"token"` Token string `yaml:"token"`
@@ -135,9 +153,11 @@ func defaultSources() Sources {
"maven.pom_paths": "default", "maven.pom_paths": "default",
"node.package_json": "default", "node.package_json": "default",
"node.package_jsons": "default", "node.package_jsons": "default",
"gradle.build_file": "default", "gradle.build_file": "default",
"gradle.build_files": "default", "gradle.build_files": "default",
"gitlab.url": "default", "python.pyproject_toml": "default",
"python.pyproject_tomls": "default",
"gitlab.url": "default",
"gitlab.token": "default", "gitlab.token": "default",
"gitlab.project": "default", "gitlab.project": "default",
"github.token": "default", "github.token": "default",
@@ -219,6 +239,12 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if len(overlay.Gradle.BuildFiles) > 0 { if len(overlay.Gradle.BuildFiles) > 0 {
src["gradle.build_files"] = "config file" src["gradle.build_files"] = "config file"
} }
if overlay.Python.PyprojectTOML != "" {
src["python.pyproject_toml"] = "config file"
}
if len(overlay.Python.PyprojectTOMLs) > 0 {
src["python.pyproject_tomls"] = "config file"
}
if overlay.GitLab.URL != "" { if overlay.GitLab.URL != "" {
src["gitlab.url"] = "config file" src["gitlab.url"] = "config file"
} }
+43
View File
@@ -338,6 +338,49 @@ func TestLoadGradleBuildFilesSources(t *testing.T) {
} }
} }
func TestPythonEffectivePaths(t *testing.T) {
if got := (PythonConfig{}).EffectivePaths(); got != nil {
t.Errorf("empty config: got %v, want nil", got)
}
if got := (PythonConfig{PyprojectTOML: "pyproject.toml"}).EffectivePaths(); len(got) != 1 || got[0] != "pyproject.toml" {
t.Errorf("single path: got %v", got)
}
p := PythonConfig{PyprojectTOML: "pyproject.toml", PyprojectTOMLs: []string{"a/pyproject.toml", "b/pyproject.toml"}}
if got := p.EffectivePaths(); len(got) != 2 || got[0] != "a/pyproject.toml" {
t.Errorf("multi-path priority: got %v", got)
}
}
func TestLoadPythonSources(t *testing.T) {
dir := t.TempDir()
content := "python:\n pyproject_toml: \"pyproject.toml\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
if src["python.pyproject_toml"] != "config file" {
t.Errorf("src[python.pyproject_toml] = %q, want %q", src["python.pyproject_toml"], "config file")
}
}
func TestLoadPythonPathsSources(t *testing.T) {
dir := t.TempDir()
content := "python:\n pyproject_tomls:\n - \"a/pyproject.toml\"\n - \"b/pyproject.toml\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
if src["python.pyproject_tomls"] != "config file" {
t.Errorf("src[python.pyproject_tomls] = %q, want %q", src["python.pyproject_tomls"], "config file")
}
}
func TestLoadPartialOverride(t *testing.T) { func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default // Only override tag_prefix — commit_message should keep its default
+74
View File
@@ -0,0 +1,74 @@
package pyproject
import (
"fmt"
"os"
"regexp"
)
// sectionRe matches a TOML section from its header to the next header (or end of file).
// TOML section headers always appear at the start of a line, so \n[ is a reliable boundary.
var (
projectSectionRe = regexp.MustCompile(`(?s)\[project\].*?(?:\n\[|\z)`)
poetrySectionRe = regexp.MustCompile(`(?s)\[tool\.poetry\].*?(?:\n\[|\z)`)
)
// versionLineRe matches version = "x.y.z" on its own line inside a section.
var versionLineRe = regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`)
// ReadVersion returns the project version from a pyproject.toml file.
// It checks [project] (PEP 621) first, then [tool.poetry] (Poetry).
func ReadVersion(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
content := string(data)
for _, sectionRe := range []*regexp.Regexp{projectSectionRe, poetrySectionRe} {
section := sectionRe.FindString(content)
if section == "" {
continue
}
m := versionLineRe.FindStringSubmatch(section)
if m != nil {
return m[1], nil
}
}
return "", fmt.Errorf("no version found in %s", path)
}
// WriteVersion replaces the project version in a pyproject.toml file in-place.
// oldVersion must match what ReadVersion returned.
func WriteVersion(path, oldVersion, newVersion string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
updated, ok := replaceVersion(string(data), oldVersion, newVersion)
if !ok {
return fmt.Errorf("version %q not found in %s", oldVersion, path)
}
return os.WriteFile(path, []byte(updated), 0644)
}
// replaceVersion finds and replaces the version in the first [project] or [tool.poetry]
// section that contains it. Returns the updated content and true on success.
// regexp.QuoteMeta is used so version strings with dots or specials are safe.
func replaceVersion(content, oldVersion, newVersion string) (string, bool) {
re := regexp.MustCompile(`(?m)^(version\s*=\s*)"` + regexp.QuoteMeta(oldVersion) + `"`)
for _, sectionRe := range []*regexp.Regexp{projectSectionRe, poetrySectionRe} {
loc := sectionRe.FindStringIndex(content)
if loc == nil {
continue
}
section := content[loc[0]:loc[1]]
m := re.FindStringSubmatchIndex(section)
if m == nil {
continue
}
// m[0]:m[1] = full match; m[2]:m[3] = group 1 (prefix "version = ")
newSection := section[:m[0]] + section[m[2]:m[3]] + `"` + newVersion + `"` + section[m[1]:]
return content[:loc[0]] + newSection + content[loc[1]:], true
}
return content, false
}
+232
View File
@@ -0,0 +1,232 @@
package pyproject
import (
"os"
"path/filepath"
"strings"
"testing"
)
const pyprojectPEP621 = `[project]
name = "my-package"
version = "1.2.3"
description = "A test package"
dependencies = ["requests>=2.0", "click>=8.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
`
const pyprojectPoetry = `[tool.poetry]
name = "my-package"
version = "1.2.3"
description = "A test package"
[tool.poetry.dependencies]
python = "^3.9"
requests = "^2.0"
`
const pyprojectBoth = `[project]
name = "my-package"
version = "1.2.3"
[tool.poetry]
name = "my-package"
version = "1.2.3"
`
const pyprojectBuildSystemFirst = `[build-system]
requires = ["hatchling"]
[project]
name = "my-package"
version = "1.2.3"
`
func writePyproject(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pyproject.toml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
// ── ReadVersion ───────────────────────────────────────────────────────────────
func TestReadVersionPEP621(t *testing.T) {
got, err := ReadVersion(writePyproject(t, pyprojectPEP621))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionPoetry(t *testing.T) {
got, err := ReadVersion(writePyproject(t, pyprojectPoetry))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionPEP621TakesPrecedence(t *testing.T) {
// When both [project] and [tool.poetry] are present, [project] wins.
got, err := ReadVersion(writePyproject(t, pyprojectBoth))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionBuildSystemIgnored(t *testing.T) {
// version under [build-system] must not be returned.
got, err := ReadVersion(writePyproject(t, pyprojectBuildSystemFirst))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "pyproject.toml"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestReadVersionNoVersion(t *testing.T) {
_, err := ReadVersion(writePyproject(t, `[build-system]
requires = ["hatchling"]
`))
if err == nil {
t.Error("expected error when no version is found")
}
}
// ── WriteVersion ──────────────────────────────────────────────────────────────
func TestWriteVersionPEP621(t *testing.T) {
path := writePyproject(t, pyprojectPEP621)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
s := string(data)
if !strings.Contains(s, `version = "1.2.4"`) {
t.Errorf("expected updated version; got:\n%s", s)
}
// build-system section must not be touched
if strings.Contains(s, `"hatchling>=1.2.4"`) {
t.Error("build-system section was incorrectly modified")
}
}
func TestWriteVersionPoetry(t *testing.T) {
path := writePyproject(t, pyprojectPoetry)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), `version = "1.2.4"`) {
t.Errorf("expected updated version; got:\n%s", data)
}
}
func TestWriteVersionBothSectionsUpdatesProject(t *testing.T) {
path := writePyproject(t, pyprojectBoth)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
s := string(data)
// [project] version updated
if !strings.Contains(s, `[project]`) || strings.Index(s, `version = "1.2.4"`) > strings.Index(s, `[tool.poetry]`) {
t.Error("expected [project] version to be updated first")
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "pyproject.toml"), "1.0.0", "1.0.1")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
path := writePyproject(t, pyprojectPEP621)
err := WriteVersion(path, "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found")
}
}
func TestWriteVersionReadOnly(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
path := writePyproject(t, pyprojectPEP621)
os.Chmod(path, 0444)
defer os.Chmod(path, 0644)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err == nil {
t.Error("expected error writing to read-only file")
}
}
// ── replaceVersion ────────────────────────────────────────────────────────────
func TestReplaceVersionNotFound(t *testing.T) {
content := `[build-system]
requires = ["hatchling"]
`
got, ok := replaceVersion(content, "1.0.0", "1.0.1")
if ok {
t.Error("expected ok=false when no matching section")
}
if got != content {
t.Error("content should be unchanged when not found")
}
}
// ── fuzz ─────────────────────────────────────────────────────────────────────
// FuzzReadVersion verifies ReadVersion never panics on arbitrary file content.
func FuzzReadVersion(f *testing.F) {
f.Add(pyprojectPEP621)
f.Add(pyprojectPoetry)
f.Add(pyprojectBoth)
f.Add(`[project]` + "\n")
f.Add("")
f.Add("\x00\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "pyproject.toml")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
ReadVersion(path) //nolint:errcheck
})
}
// FuzzWriteVersion verifies WriteVersion never panics on arbitrary content or version strings.
func FuzzWriteVersion(f *testing.F) {
f.Add(pyprojectPEP621, "1.2.3", "1.2.4")
f.Add(pyprojectPoetry, "1.2.3", "1.2.4")
f.Add(pyprojectBoth, "1.2.3", "1.2.4")
f.Add("", "1.0.0", "1.0.1")
f.Add(`[project]`+"\n"+"version = \"1.0.0\"\n", "1.0.0", "1.0.1")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
path := filepath.Join(t.TempDir(), "pyproject.toml")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
WriteVersion(path, oldVersion, newVersion) //nolint:errcheck
})
}