Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The plugin-template Authors
// SPDX-FileCopyrightText: 2026 The semrel Authors

package main

import (
"context"
"log"
"os"

grpcserver "github.com/SemRels/updater-python/internal/grpc"
semrelplugin "github.com/SemRels/updater-python/internal/plugin"
plugin "github.com/SemRels/updater-python/internal/plugin"
)

func main() {
provider := semrelplugin.NewProvider("updater-python")
server := grpcserver.NewProviderServer(provider)

if _, err := server.Health(context.Background()); err != nil {
log.Printf("plugin health check failed: %v", err)
os.Exit(1)
}

log.Printf("%s plugin template is ready", provider.Name())
publisher := plugin.NewPublisher(plugin.Config{})
log.Printf("updater-python plugin ready: updates Python package metadata and uploads distributions (%T)", publisher)
}
8 changes: 0 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,3 @@ module github.com/SemRels/updater-python
go 1.24

toolchain go1.24.0

require github.com/stretchr/testify v1.10.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 0 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
32 changes: 0 additions & 32 deletions internal/grpc/server.go

This file was deleted.

33 changes: 0 additions & 33 deletions internal/plugin/provider.go

This file was deleted.

28 changes: 0 additions & 28 deletions internal/plugin/provider_test.go

This file was deleted.

201 changes: 201 additions & 0 deletions internal/plugin/publisher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The semrel Authors

// Package plugin provides Python package versioning and PyPI publishing.
// It updates version references in pyproject.toml (PEP 621/Poetry) and
// setup.cfg/setup.py, and publishes packages to PyPI using twine or the
// PyPA build + upload workflow.
package plugin

import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
)

// PyprojectToml holds the minimal fields from a pyproject.toml.
type PyprojectToml struct {
// Name is the package name.
Name string
// Version is the current package version.
Version string
}

// UpdatePyprojectVersion reads pyproject.toml, updates the version in [project]
// or [tool.poetry] section, and writes the file back.
func UpdatePyprojectVersion(path, version string) (*PyprojectToml, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("python: read pyproject.toml: %w", err)
}

updated, meta, err := updatePyprojectTOML(data, version)
if err != nil {
return nil, err
}

if err := os.WriteFile(path, updated, 0o644); err != nil {
return nil, fmt.Errorf("python: write pyproject.toml: %w", err)
}
return meta, nil
}

func updatePyprojectTOML(data []byte, version string) ([]byte, *PyprojectToml, error) {
versionRe := regexp.MustCompile(`^(version\s*=\s*)"[^"]*"`)
nameRe := regexp.MustCompile(`^(name\s*=\s*)"([^"]*)"`)

var (
lines []string
inSection bool
versionSet bool
meta PyprojectToml
)

scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)

if strings.HasPrefix(trimmed, "[") {
// Match [project] or [tool.poetry] sections
section := strings.ToLower(trimmed)
inSection = section == "[project]" || section == "[tool.poetry]"
}

if inSection {
if m := nameRe.FindStringSubmatch(trimmed); m != nil {
meta.Name = m[2]
}
if !versionSet && versionRe.MatchString(trimmed) {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
line = indent + fmt.Sprintf(`version = "%s"`, version)
versionSet = true
meta.Version = version
}
}

lines = append(lines, line)
}
if err := scanner.Err(); err != nil {
return nil, nil, fmt.Errorf("python: scan pyproject.toml: %w", err)
}
if !versionSet {
return nil, nil, fmt.Errorf("python: version field not found in pyproject.toml")
}
return []byte(strings.Join(lines, "\n")), &meta, nil
}

// UpdateSetupCfgVersion reads setup.cfg, updates the version field in [metadata],
// and writes the file back.
func UpdateSetupCfgVersion(path, version string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("python: read setup.cfg: %w", err)
}

versionRe := regexp.MustCompile(`^(version\s*=\s*)\S+`)
inMetadata := false
var lines []string
versionSet := false

scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)

if strings.HasPrefix(trimmed, "[") {
inMetadata = strings.ToLower(trimmed) == "[metadata]"
}

if inMetadata && !versionSet && versionRe.MatchString(trimmed) {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
line = indent + "version = " + version
versionSet = true
}
lines = append(lines, line)
}

return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}

// Publisher publishes Python packages to PyPI (or a custom index).
type Publisher struct {
cfg Config
}

// Config holds the PyPI publishing configuration.
type Config struct {
// Repository is the PyPI repository URL (defaults to https://upload.pypi.org/legacy/).
Repository string
// Username is the PyPI username (use "__token__" with API tokens).
Username string
// Password is the PyPI password or API token.
Password string
// SkipExisting skips upload if the version already exists (--skip-existing).
SkipExisting bool
}

// NewPublisher creates a Publisher with the given configuration.
func NewPublisher(cfg Config) *Publisher {
if cfg.Repository == "" {
cfg.Repository = "https://upload.pypi.org/legacy/"
}
return &Publisher{cfg: cfg}
}

// Build runs the Python build system (python -m build) to create dist/ artifacts.
func (p *Publisher) Build(ctx context.Context, packageDir string) error {
cmd := exec.CommandContext(ctx, "python", "-m", "build")
cmd.Dir = packageDir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("python: build: %w\n%s", err, out)
}
return nil
}

// UploadWithTwine runs twine upload to publish dist artifacts.
func (p *Publisher) UploadWithTwine(ctx context.Context, packageDir, distGlob string) error {
if distGlob == "" {
distGlob = "dist/*"
}
args := []string{"upload", "--repository-url", p.cfg.Repository}
if p.cfg.SkipExisting {
args = append(args, "--skip-existing")
}
args = append(args, distGlob)

cmd := exec.CommandContext(ctx, "twine", args...)
cmd.Dir = packageDir
env := os.Environ()
if p.cfg.Username != "" {
env = append(env, "TWINE_USERNAME="+p.cfg.Username)
}
if p.cfg.Password != "" {
env = append(env, "TWINE_PASSWORD="+p.cfg.Password)
}
cmd.Env = env

if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("python: twine upload: %w\n%s", err, out)
}
return nil
}

// IsTwineAvailable reports whether twine is installed.
func IsTwineAvailable() bool {
_, err := exec.LookPath("twine")
return err == nil
}

// IsPythonAvailable reports whether python is installed.
func IsPythonAvailable() bool {
_, err := exec.LookPath("python")
if err != nil {
_, err = exec.LookPath("python3")
}
return err == nil
}
Loading
Loading