a14c3f1181
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>
75 lines
2.5 KiB
Go
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
|
|
}
|