@@ -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
6162exec 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 {
458478func 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
491570func TestRegistryDepsHaveRegistryConfig (t * testing.T ) {
0 commit comments