Skip to content

Commit 8110a05

Browse files
authored
Add a more comprehensive python deps test (#2675)
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.
1 parent 3eeff48 commit 8110a05

3 files changed

Lines changed: 103 additions & 22 deletions

File tree

‎plugins/community/nipunn1313-mypy-grpc/v5.1.0/buf.plugin.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ registry:
1111
python:
1212
package_type: "stub-only"
1313
# https://github.com/nipunn1313/mypy-protobuf/blob/v5.1.0/pyproject.toml#L21
14-
requires_python: ">=3.8"
14+
# Bumped to match protobuf requirement.
15+
requires_python: ">=3.9"
1516
deps:
1617
# https://github.com/nipunn1313/mypy-protobuf/tree/v5.1.0#requirements-to-run-typecheckers-on-stubs-generated-by-mypy-protobuf
1718
# > Earlier releases might work, but aren't tested.

‎plugins/community/nipunn1313-mypy/v5.1.0/buf.plugin.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ registry:
1111
python:
1212
package_type: "stub-only"
1313
# https://github.com/nipunn1313/mypy-protobuf/blob/v5.1.0/pyproject.toml#L21
14-
requires_python: ">=3.8"
14+
# Bumped to match protobuf requirement.
15+
requires_python: ">=3.9"
1516
deps:
1617
# https://github.com/nipunn1313/mypy-protobuf/tree/v5.1.0#requirements-to-run-typecheckers-on-stubs-generated-by-mypy-protobuf
1718
# > Earlier releases might work, but aren't tested.

‎tests/plugins_test.go‎

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"os/exec"
1717
"path/filepath"
1818
"reflect"
19+
"slices"
1920
"strconv"
2021
"strings"
2122
"testing"
@@ -59,6 +60,25 @@ plugins:
5960
protocGenPluginTemplate = template.Must(template.New("protoc-gen-plugin").Parse(`#!/bin/bash
6061
6162
exec docker run --log-driver=none --rm -i {{.ImageName}}:{{.Version}} "$@"
63+
`))
64+
pyprojectTemplate = template.Must(template.New("pyproject.toml").Parse(`[project]
65+
name = {{ printf "%q" .Name }}
66+
version = {{ printf "%q" .Version }}
67+
{{- if .RequiresPython }}
68+
requires-python = {{ printf "%q" .RequiresPython }}
69+
{{- end }}
70+
dependencies = [
71+
{{- range .Deps }}
72+
{{ printf "%q" . }},
73+
{{- end }}
74+
]
75+
{{- if .Sources }}
76+
77+
[tool.uv.sources]
78+
{{- range .Sources }}
79+
{{ .Name }} = { path = {{ printf "%q" .Path }} }
80+
{{- end }}
81+
{{- end }}
6282
`))
6383
images = []string{
6484
"eliza",
@@ -458,34 +478,93 @@ func cargoReqMatches(req, version string) bool {
458478
func TestPyPIDependencies(t *testing.T) {
459479
t.Parallel()
460480
ctx := t.Context()
461-
plugins := loadFilteredPlugins(t)
462-
for _, p := range plugins {
463-
if p.Registry.Python == nil || len(p.Registry.Python.Deps) == 0 {
481+
allPlugins := loadAllPlugins(t)
482+
pluginByRef := make(map[string]*plugin.Plugin, len(allPlugins))
483+
for _, p := range allPlugins {
484+
pluginByRef[p.String()] = p
485+
}
486+
for _, p := range loadFilteredPlugins(t) {
487+
if p.Registry.Python == nil {
464488
continue
465489
}
466-
// https://docs.astral.sh/uv/getting-started/installation/
467-
_, err := exec.LookPath("uv")
468-
require.NoError(t, err, "uv must be installed to run this test")
469490
t.Run(fmt.Sprintf("%s/%s@%s", p.Identity.Owner(), p.Identity.Plugin(), p.PluginVersion), func(t *testing.T) {
470491
t.Parallel()
492+
_, err := exec.LookPath("uv")
493+
require.NoError(t, err, "uv must be installed to run this test")
494+
// Model every plugin in the dependency closure as its own
495+
// distribution, matching how the BSR publishes one SDK package per
496+
// plugin. Resolving the graph rather than a flattened list of PyPI
497+
// specifiers also verifies that each plugin's requires_python is
498+
// compatible with the SDKs it depends on.
471499
tmpdir := t.TempDir()
500+
writePythonSDKProjects(t, tmpdir, p, pluginByRef, make(map[string]bool))
501+
// uv lock resolves the whole graph against PyPI without creating a
502+
// virtualenv or installing anything.
503+
uvLockCmd := exec.CommandContext(ctx, "uv", "lock")
504+
uvLockCmd.Dir = filepath.Join(tmpdir, pythonSDKDirName(p))
505+
output, err := uvLockCmd.CombinedOutput()
506+
require.NoErrorf(t, err, "uv lock failed - output: %s", string(output))
507+
})
508+
}
509+
}
472510

473-
uvInitCmd := exec.CommandContext(ctx, "uv", "init")
474-
uvInitCmd.Dir = tmpdir
475-
output, err := uvInitCmd.CombinedOutput()
476-
require.NoErrorf(t, err, "uv init failed - output: %s", string(output))
477-
478-
// Make sure we can add all dependencies with `uv add`, which will lookup
479-
// the dependency specifier from pypi and make sure that we can resolve
480-
// versions.
481-
for _, dep := range p.Registry.Python.Deps {
482-
uvInitCmd := exec.CommandContext(ctx, "uv", "add", dep)
483-
uvInitCmd.Dir = tmpdir
484-
output, err := uvInitCmd.CombinedOutput()
485-
require.NoErrorf(t, err, "uv add failed - output: %s", string(output))
486-
}
511+
// pythonSDKDistName returns the distribution name used to model the Python SDK
512+
// generated by a plugin.
513+
func pythonSDKDistName(p *plugin.Plugin) string {
514+
return p.Identity.Owner() + "-" + p.Identity.Plugin()
515+
}
516+
517+
// pythonSDKDirName returns the directory holding a plugin's generated pyproject.toml.
518+
func pythonSDKDirName(p *plugin.Plugin) string {
519+
return pythonSDKDistName(p) + "-" + strings.TrimPrefix(p.PluginVersion, "v")
520+
}
521+
522+
// writePythonSDKProjects writes a pyproject.toml for pythonPlugin and for every
523+
// plugin in its dependency closure, wiring plugin deps as path dependencies.
524+
func writePythonSDKProjects(
525+
t *testing.T,
526+
dir string,
527+
pythonPlugin *plugin.Plugin,
528+
pluginByRef map[string]*plugin.Plugin,
529+
written map[string]bool,
530+
) {
531+
t.Helper()
532+
if written[pythonPlugin.String()] {
533+
return
534+
}
535+
written[pythonPlugin.String()] = true
536+
type pathSource struct {
537+
Name string
538+
Path string
539+
}
540+
deps := slices.Clone(pythonPlugin.Registry.Python.Deps)
541+
var sources []pathSource
542+
for _, pluginDependency := range pythonPlugin.Deps {
543+
pluginDep, ok := pluginByRef[pluginDependency.Plugin]
544+
require.Truef(t, ok, "dependency %q of plugin %q not found", pluginDependency.Plugin, pythonPlugin.String())
545+
if pluginDep.Registry.Python == nil {
546+
continue
547+
}
548+
deps = append(deps, pythonSDKDistName(pluginDep))
549+
sources = append(sources, pathSource{
550+
Name: pythonSDKDistName(pluginDep),
551+
Path: "../" + pythonSDKDirName(pluginDep),
487552
})
553+
writePythonSDKProjects(t, dir, pluginDep, pluginByRef, written)
488554
}
555+
slices.Sort(deps)
556+
slices.SortFunc(sources, func(a, b pathSource) int { return cmp.Compare(a.Name, b.Name) })
557+
projectDir := filepath.Join(dir, pythonSDKDirName(pythonPlugin))
558+
require.NoError(t, os.MkdirAll(projectDir, 0o755))
559+
var pyproject bytes.Buffer
560+
require.NoError(t, pyprojectTemplate.Execute(&pyproject, map[string]any{
561+
"Name": pythonSDKDistName(pythonPlugin),
562+
"Version": strings.TrimPrefix(pythonPlugin.PluginVersion, "v"),
563+
"RequiresPython": pythonPlugin.Registry.Python.RequiresPython,
564+
"Deps": deps,
565+
"Sources": sources,
566+
}))
567+
require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), pyproject.Bytes(), 0o644))
489568
}
490569

491570
func TestRegistryDepsHaveRegistryConfig(t *testing.T) {

0 commit comments

Comments
 (0)