diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 565f468..9f81a18 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -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) } diff --git a/go.mod b/go.mod index c0b9e0c..a83e4f4 100644 --- a/go.mod +++ b/go.mod @@ -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 -) diff --git a/go.sum b/go.sum index 713a0b4..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/grpc/server.go b/internal/grpc/server.go deleted file mode 100644 index 5422eb0..0000000 --- a/internal/grpc/server.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package grpc - -import ( - "context" - - semrelplugin "github.com/SemRels/updater-python/internal/plugin" -) - -// HealthResponse is a lightweight stand-in until generated protobuf bindings are wired in. -type HealthResponse struct { - Name string -} - -// ProviderServer adapts a provider implementation for the future gRPC transport layer. -type ProviderServer struct { - provider semrelplugin.Provider -} - -func NewProviderServer(provider semrelplugin.Provider) *ProviderServer { - return &ProviderServer{provider: provider} -} - -func (s *ProviderServer) Health(ctx context.Context) (*HealthResponse, error) { - if err := s.provider.HealthCheck(ctx); err != nil { - return nil, err - } - - return &HealthResponse{Name: s.provider.Name()}, nil -} diff --git a/internal/plugin/provider.go b/internal/plugin/provider.go deleted file mode 100644 index f7be062..0000000 --- a/internal/plugin/provider.go +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import "context" - -// Provider defines the minimal contract a SemRel provider plugin should implement. -type Provider interface { - Name() string - HealthCheck(context.Context) error -} - -// ProviderPlugin is a small default implementation that can be extended or replaced. -type ProviderPlugin struct { - name string -} - -func NewProvider(name string) *ProviderPlugin { - if name == "" { - name = "updater-python" - } - - return &ProviderPlugin{name: name} -} - -func (p *ProviderPlugin) Name() string { - return p.name -} - -func (p *ProviderPlugin) HealthCheck(context.Context) error { - return nil -} diff --git a/internal/plugin/provider_test.go b/internal/plugin/provider_test.go deleted file mode 100644 index 54239e9..0000000 --- a/internal/plugin/provider_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewProviderDefaultsName(t *testing.T) { - t.Parallel() - - provider := NewProvider("") - - require.Equal(t, "updater-python", provider.Name()) - require.NoError(t, provider.HealthCheck(context.Background())) -} - -func TestNewProviderUsesProvidedName(t *testing.T) { - t.Parallel() - - provider := NewProvider("provider-example") - - require.Equal(t, "provider-example", provider.Name()) -} diff --git a/internal/plugin/publisher.go b/internal/plugin/publisher.go new file mode 100644 index 0000000..d2b2523 --- /dev/null +++ b/internal/plugin/publisher.go @@ -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 +} diff --git a/internal/plugin/publisher_test.go b/internal/plugin/publisher_test.go new file mode 100644 index 0000000..2f21df4 --- /dev/null +++ b/internal/plugin/publisher_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +package plugin_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + python "github.com/SemRels/updater-python/internal/plugin" +) + +func writePyproject(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "pyproject.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestUpdatePyprojectVersion_PEP621(t *testing.T) { + dir := t.TempDir() + path := writePyproject(t, dir, `[project] +name = "mypackage" +version = "0.1.0" +description = "A sample package" +`) + + meta, err := python.UpdatePyprojectVersion(path, "1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Version != "1.2.3" { + t.Errorf("expected version 1.2.3, got %q", meta.Version) + } + if meta.Name != "mypackage" { + t.Errorf("expected name mypackage, got %q", meta.Name) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `version = "1.2.3"`) { + t.Error("pyproject.toml should contain updated version") + } +} + +func TestUpdatePyprojectVersion_Poetry(t *testing.T) { + dir := t.TempDir() + path := writePyproject(t, dir, `[tool.poetry] +name = "poetry-pkg" +version = "0.2.0" + +[tool.poetry.dependencies] +python = "^3.9" +`) + + meta, err := python.UpdatePyprojectVersion(path, "2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Version != "2.0.0" { + t.Errorf("expected version 2.0.0, got %q", meta.Version) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `version = "2.0.0"`) { + t.Error("pyproject.toml should contain updated version") + } +} + +func TestUpdatePyprojectVersion_PreservesFields(t *testing.T) { + dir := t.TempDir() + path := writePyproject(t, dir, `[project] +name = "mypkg" +version = "1.0.0" +description = "My package" +requires-python = ">=3.8" + +[build-system] +requires = ["setuptools"] +`) + + _, err := python.UpdatePyprojectVersion(path, "1.1.0") + if err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `requires-python = ">=3.8"`) { + t.Error("UpdatePyprojectVersion should preserve requires-python field") + } + if !strings.Contains(string(data), `requires = ["setuptools"]`) { + t.Error("UpdatePyprojectVersion should preserve build-system section") + } +} + +func TestUpdatePyprojectVersion_NoVersion(t *testing.T) { + dir := t.TempDir() + path := writePyproject(t, dir, "[project]\nname = \"broken\"\n") + + _, err := python.UpdatePyprojectVersion(path, "1.0.0") + if err == nil { + t.Error("expected error when version not found") + } +} + +func TestUpdateSetupCfgVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "setup.cfg") + os.WriteFile(path, []byte("[metadata]\nname = mypkg\nversion = 0.1.0\n"), 0o644) + + if err := python.UpdateSetupCfgVersion(path, "2.3.4"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "version = 2.3.4") { + t.Errorf("setup.cfg should contain updated version, got: %s", data) + } +} + +func TestIsTwineAvailable(t *testing.T) { + _ = python.IsTwineAvailable() +} + +func TestIsPythonAvailable(t *testing.T) { + _ = python.IsPythonAvailable() +}