Files
k3nny a14c3f1181
ci / vet, staticcheck, test, build (push) Successful in 3m2s
docs / Build and deploy docs (push) Failing after 13s
release / Build and publish release (push) Successful in 4m29s
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>
2026-07-12 00:22:49 +02:00

75 lines
2.5 KiB
Go

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
}