From c00254a010ec6fe9ca32f4514cb3580c2c97d9b1 Mon Sep 17 00:00:00 2001 From: "Philip K. Warren" Date: Fri, 21 Aug 2026 15:45:35 -0500 Subject: [PATCH 1/2] Add a more comprehensive python deps test Instead of just verifying the versions are valid for an individual plugin, build a directory structure containing a plugin and its dependencies and attempt to resolve everything together. This catches issues like #2674 and also validates the requires-python values. The requires-python validation caught two issues in mypy plugins at the latest version. --- .../v5.1.0/buf.plugin.yaml | 3 +- .../nipunn1313-mypy/v5.1.0/buf.plugin.yaml | 3 +- plugins/connectrpc/py/v0.11.1/buf.plugin.yaml | 2 +- tests/plugins_test.go | 119 +++++++++++++++--- 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/plugins/community/nipunn1313-mypy-grpc/v5.1.0/buf.plugin.yaml b/plugins/community/nipunn1313-mypy-grpc/v5.1.0/buf.plugin.yaml index ec6292db3..c9af277d3 100644 --- a/plugins/community/nipunn1313-mypy-grpc/v5.1.0/buf.plugin.yaml +++ b/plugins/community/nipunn1313-mypy-grpc/v5.1.0/buf.plugin.yaml @@ -11,7 +11,8 @@ registry: python: package_type: "stub-only" # https://github.com/nipunn1313/mypy-protobuf/blob/v5.1.0/pyproject.toml#L21 - requires_python: ">=3.8" + # Bumped to match protobuf requirement. + requires_python: ">=3.9" deps: # https://github.com/nipunn1313/mypy-protobuf/tree/v5.1.0#requirements-to-run-typecheckers-on-stubs-generated-by-mypy-protobuf # > Earlier releases might work, but aren't tested. diff --git a/plugins/community/nipunn1313-mypy/v5.1.0/buf.plugin.yaml b/plugins/community/nipunn1313-mypy/v5.1.0/buf.plugin.yaml index b17799ee2..44b6b4c25 100644 --- a/plugins/community/nipunn1313-mypy/v5.1.0/buf.plugin.yaml +++ b/plugins/community/nipunn1313-mypy/v5.1.0/buf.plugin.yaml @@ -11,7 +11,8 @@ registry: python: package_type: "stub-only" # https://github.com/nipunn1313/mypy-protobuf/blob/v5.1.0/pyproject.toml#L21 - requires_python: ">=3.8" + # Bumped to match protobuf requirement. + requires_python: ">=3.9" deps: # https://github.com/nipunn1313/mypy-protobuf/tree/v5.1.0#requirements-to-run-typecheckers-on-stubs-generated-by-mypy-protobuf # > Earlier releases might work, but aren't tested. diff --git a/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml b/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml index e3b90e1a5..241a6b988 100644 --- a/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml +++ b/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml @@ -7,7 +7,7 @@ spdx_license_id: Apache-2.0 license_url: https://github.com/connectrpc/connect-py/blob/v0.11.1/LICENSE deps: # https://github.com/connectrpc/connect-py/blob/v0.11.1/pyproject.toml#L32 - - plugin: buf.build/bufbuild/py:v0.1.1 + - plugin: buf.build/bufbuild/py:v0.2.0 output_languages: - python registry: diff --git a/tests/plugins_test.go b/tests/plugins_test.go index cbf6a0fc9..6b5bc8585 100644 --- a/tests/plugins_test.go +++ b/tests/plugins_test.go @@ -16,6 +16,7 @@ import ( "os/exec" "path/filepath" "reflect" + "slices" "strconv" "strings" "testing" @@ -59,6 +60,25 @@ plugins: protocGenPluginTemplate = template.Must(template.New("protoc-gen-plugin").Parse(`#!/bin/bash exec docker run --log-driver=none --rm -i {{.ImageName}}:{{.Version}} "$@" +`)) + pyprojectTemplate = template.Must(template.New("pyproject.toml").Parse(`[project] +name = {{ printf "%q" .Name }} +version = {{ printf "%q" .Version }} +{{- if .RequiresPython }} +requires-python = {{ printf "%q" .RequiresPython }} +{{- end }} +dependencies = [ +{{- range .Deps }} + {{ printf "%q" . }}, +{{- end }} +] +{{- if .Sources }} + +[tool.uv.sources] +{{- range .Sources }} +{{ .Name }} = { path = {{ printf "%q" .Path }} } +{{- end }} +{{- end }} `)) images = []string{ "eliza", @@ -458,34 +478,93 @@ func cargoReqMatches(req, version string) bool { func TestPyPIDependencies(t *testing.T) { t.Parallel() ctx := t.Context() - plugins := loadFilteredPlugins(t) - for _, p := range plugins { - if p.Registry.Python == nil || len(p.Registry.Python.Deps) == 0 { + allPlugins := loadAllPlugins(t) + pluginByRef := make(map[string]*plugin.Plugin, len(allPlugins)) + for _, p := range allPlugins { + pluginByRef[p.String()] = p + } + for _, p := range loadFilteredPlugins(t) { + if p.Registry.Python == nil { continue } - // https://docs.astral.sh/uv/getting-started/installation/ - _, err := exec.LookPath("uv") - require.NoError(t, err, "uv must be installed to run this test") t.Run(fmt.Sprintf("%s/%s@%s", p.Identity.Owner(), p.Identity.Plugin(), p.PluginVersion), func(t *testing.T) { t.Parallel() + _, err := exec.LookPath("uv") + require.NoError(t, err, "uv must be installed to run this test") + // Model every plugin in the dependency closure as its own + // distribution, matching how the BSR publishes one SDK package per + // plugin. Resolving the graph rather than a flattened list of PyPI + // specifiers also verifies that each plugin's requires_python is + // compatible with the SDKs it depends on. tmpdir := t.TempDir() + writePythonSDKProjects(t, tmpdir, p, pluginByRef, make(map[string]bool)) + // uv lock resolves the whole graph against PyPI without creating a + // virtualenv or installing anything. + uvLockCmd := exec.CommandContext(ctx, "uv", "lock") + uvLockCmd.Dir = filepath.Join(tmpdir, pythonSDKDirName(p)) + output, err := uvLockCmd.CombinedOutput() + require.NoErrorf(t, err, "uv lock failed - output: %s", string(output)) + }) + } +} - uvInitCmd := exec.CommandContext(ctx, "uv", "init") - uvInitCmd.Dir = tmpdir - output, err := uvInitCmd.CombinedOutput() - require.NoErrorf(t, err, "uv init failed - output: %s", string(output)) - - // Make sure we can add all dependencies with `uv add`, which will lookup - // the dependency specifier from pypi and make sure that we can resolve - // versions. - for _, dep := range p.Registry.Python.Deps { - uvInitCmd := exec.CommandContext(ctx, "uv", "add", dep) - uvInitCmd.Dir = tmpdir - output, err := uvInitCmd.CombinedOutput() - require.NoErrorf(t, err, "uv add failed - output: %s", string(output)) - } +// pythonSDKDistName returns the distribution name used to model the Python SDK +// generated by a plugin. +func pythonSDKDistName(p *plugin.Plugin) string { + return p.Identity.Owner() + "-" + p.Identity.Plugin() +} + +// pythonSDKDirName returns the directory holding a plugin's generated pyproject.toml. +func pythonSDKDirName(p *plugin.Plugin) string { + return pythonSDKDistName(p) + "-" + strings.TrimPrefix(p.PluginVersion, "v") +} + +// writePythonSDKProjects writes a pyproject.toml for pythonPlugin and for every +// plugin in its dependency closure, wiring plugin deps as path dependencies. +func writePythonSDKProjects( + t *testing.T, + dir string, + pythonPlugin *plugin.Plugin, + pluginByRef map[string]*plugin.Plugin, + written map[string]bool, +) { + t.Helper() + if written[pythonPlugin.String()] { + return + } + written[pythonPlugin.String()] = true + type pathSource struct { + Name string + Path string + } + deps := slices.Clone(pythonPlugin.Registry.Python.Deps) + var sources []pathSource + for _, pluginDependency := range pythonPlugin.Deps { + pluginDep, ok := pluginByRef[pluginDependency.Plugin] + require.Truef(t, ok, "dependency %q of plugin %q not found", pluginDependency.Plugin, pythonPlugin.String()) + if pluginDep.Registry.Python == nil { + continue + } + deps = append(deps, pythonSDKDistName(pluginDep)) + sources = append(sources, pathSource{ + Name: pythonSDKDistName(pluginDep), + Path: "../" + pythonSDKDirName(pluginDep), }) + writePythonSDKProjects(t, dir, pluginDep, pluginByRef, written) } + slices.Sort(deps) + slices.SortFunc(sources, func(a, b pathSource) int { return cmp.Compare(a.Name, b.Name) }) + projectDir := filepath.Join(dir, pythonSDKDirName(pythonPlugin)) + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + var pyproject bytes.Buffer + require.NoError(t, pyprojectTemplate.Execute(&pyproject, map[string]any{ + "Name": pythonSDKDistName(pythonPlugin), + "Version": strings.TrimPrefix(pythonPlugin.PluginVersion, "v"), + "RequiresPython": pythonPlugin.Registry.Python.RequiresPython, + "Deps": deps, + "Sources": sources, + })) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), pyproject.Bytes(), 0o644)) } func TestRegistryDepsHaveRegistryConfig(t *testing.T) { From 81380a8b23917ad44dff5bf27a65b61c3a62bd96 Mon Sep 17 00:00:00 2001 From: "Philip K. Warren" Date: Fri, 21 Aug 2026 15:49:52 -0500 Subject: [PATCH 2/2] Revert change used for testing --- plugins/connectrpc/py/v0.11.1/buf.plugin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml b/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml index 241a6b988..e3b90e1a5 100644 --- a/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml +++ b/plugins/connectrpc/py/v0.11.1/buf.plugin.yaml @@ -7,7 +7,7 @@ spdx_license_id: Apache-2.0 license_url: https://github.com/connectrpc/connect-py/blob/v0.11.1/LICENSE deps: # https://github.com/connectrpc/connect-py/blob/v0.11.1/pyproject.toml#L32 - - plugin: buf.build/bufbuild/py:v0.2.0 + - plugin: buf.build/bufbuild/py:v0.1.1 output_languages: - python registry: