Skip to content

Commit 2cc0f20

Browse files
committed
Implements local Python SDK replacement functionality for testing Pulumi providers and programs with unpublished Python SDK builds. This feature enables developers to test against local Python SDKs using pip install -e (editable mode), following the same pattern as existing YarnLink (Node.js) and GoModReplacement (Go) features.
1. **pulumitest/opttest/opttest.go**: - Added `PythonLinks []string` field to `Options` struct to store local Python package paths - Created `PythonLink()` function to accept one or more local Python package paths - Updated `Defaults()` function to initialize `PythonLinks` slice 2. **pulumitest/newStack.go**: - Implemented pip install logic to execute `python -m pip install -e <path>` for each Python package - Uses absolute paths (consistent with GoModReplacement pattern) - Executes after YarnLink and before GoModReplacement for logical ordering - Includes proper error handling and logging 3. **pulumitest/opttest/opttest_test.go** (new file): - `TestPythonLinkOption`: Verifies single package path is appended - `TestPythonLinkMultiplePackages`: Verifies multiple package paths can be specified - `TestPythonLinkAccumulates`: Verifies packages accumulate across multiple calls - `TestDefaultsResetsPythonLinks`: Verifies Defaults() resets PythonLinks 4. **pulumitest/README.md**: - Added "Python - Local Package Installation" section - Documented PythonLink usage with examples - Followed same documentation pattern as YarnLink and GoModReplacement sections - **Editable Mode**: Uses `pip install -e` for symlinked/editable installation (same behavior as yarn link) - **Multiple Packages**: Supports installing multiple local Python packages in a single test - **Absolute Paths**: Converts relative paths to absolute paths for reliability - **Error Handling**: Clear error messages if pip install fails or paths don't exist - **Environment Aware**: Uses `python -m pip` for better virtual environment compatibility - ✅ All unit tests for PythonLink option pass (4/4 tests) - ✅ Code formatting passes (go fmt) - ✅ Code vetting passes (go vet) - ✅ Linting passes - ✅ Implementation follows existing architectural patterns for YarnLink and GoModReplacement ```go import ( "filepath" "testing" "github.com/pulumi/providertest/pulumitest" "github.com/pulumi/providertest/pulumitest/opttest" ) func TestWithLocalPythonSDK(t *testing.T) { // Use local Python SDK build test := pulumitest.NewPulumiTest(t, "test_dir", opttest.PythonLink("../sdk/python")) // Or multiple packages test2 := pulumitest.NewPulumiTest(t, "test_dir", opttest.PythonLink("../sdk/python", "../other-sdk/python")) test.Up(t) } ```
1 parent fe0a2a7 commit 2cc0f20

File tree

11 files changed

+267
-25
lines changed

11 files changed

+267
-25
lines changed

pulumitest/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ NewPulumiTest(t, "test_dir",
131131
opttest.GoModReplacement("github.com/pulumi/pulumi-my-provider/sdk/v3", "..", "sdk"))
132132
```
133133

134+
### Python - Local Package Installation
135+
136+
For Python, we support installing local packages in editable mode via `pip install -e`. This allows using a local build of the Python SDK during testing. Before running your test, ensure your Python environment is properly configured (typically within a virtual environment).
137+
138+
The local package installation can be specified using the `PythonLink` test option:
139+
140+
```go
141+
NewPulumiTest(t, "test_dir", opttest.PythonLink("../sdk/python"))
142+
```
143+
144+
Multiple packages can be specified:
145+
146+
```go
147+
NewPulumiTest(t, "test_dir",
148+
opttest.PythonLink("../sdk/python", "../other-sdk/python"))
149+
```
150+
134151
## Additional Operations
135152

136153
### Update Source

pulumitest/newStack.go

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,37 @@ func (pt *PulumiTest) NewStack(t PT, stackName string, opts ...optnewstack.NewSt
8383

8484
ptLogF(t, "creating stack %s", stackName)
8585
stack, err := auto.NewStackLocalSource(pt.ctx, stackName, pt.workingDir, stackOpts...)
86+
if err != nil {
87+
ptFatalF(t, "failed to create stack: %s", err)
88+
return nil
89+
}
90+
91+
// Register cleanup immediately after successful stack creation.
92+
// This ensures cleanup runs regardless of whether subsequent setup operations
93+
// (provider plugins, yarn links, python links, go mod replacements) succeed or fail.
94+
// If any setup operation fails, the test will fail but cleanup will still execute,
95+
// preventing resource leaks and ensuring consistent test environment cleanup.
96+
if !stackOptions.SkipDestroy {
97+
t.Cleanup(func() {
98+
t.Helper()
99+
100+
if ptFailed(t) && skipDestroyOnFailure() {
101+
t.Log("Skipping destroy because PULUMITEST_SKIP_DESTROY_ON_FAILURE is set to 'true'.")
102+
writeDestroyScript(t, stack.Workspace().WorkDir(), stackName, env)
103+
return
104+
}
105+
106+
t.Log("destroying stack, to skip this set PULUMITEST_SKIP_DESTROY_ON_FAILURE=true")
107+
_, err := stack.Destroy(pt.ctx)
108+
if err != nil {
109+
ptErrorF(t, "failed to destroy stack: %s", err)
110+
}
111+
err = stack.Workspace().RemoveStack(pt.ctx, stackName, optremove.Force())
112+
if err != nil {
113+
ptErrorF(t, "failed to remove stack: %s", err)
114+
}
115+
})
116+
}
86117

87118
providerPluginPaths := options.ProviderPluginPaths()
88119
if len(providerPluginPaths) > 0 {
@@ -148,6 +179,29 @@ func (pt *PulumiTest) NewStack(t PT, stackName string, opts ...optnewstack.NewSt
148179
}
149180
}
150181

182+
if options.PythonLinks != nil && len(options.PythonLinks) > 0 {
183+
// Determine which Python interpreter to use. Try python3 first for better
184+
// compatibility with modern systems, then fall back to python.
185+
pythonCmd := "python"
186+
if _, err := exec.LookPath("python3"); err == nil {
187+
pythonCmd = "python3"
188+
}
189+
190+
for _, pkgPath := range options.PythonLinks {
191+
absPath, err := filepath.Abs(pkgPath)
192+
if err != nil {
193+
ptFatalF(t, "failed to get absolute path for %s: %s", pkgPath, err)
194+
}
195+
cmd := exec.Command(pythonCmd, "-m", "pip", "install", "-e", absPath)
196+
cmd.Dir = pt.workingDir
197+
ptLogF(t, "installing python package: %s", cmd)
198+
out, err := cmd.CombinedOutput()
199+
if err != nil {
200+
ptFatalF(t, "failed to install python package %s: %s\n%s", pkgPath, err, out)
201+
}
202+
}
203+
}
204+
151205
if options.GoModReplacements != nil && len(options.GoModReplacements) > 0 {
152206
orderedReplacements := make([]string, 0, len(options.GoModReplacements))
153207
for old := range options.GoModReplacements {
@@ -171,31 +225,6 @@ func (pt *PulumiTest) NewStack(t PT, stackName string, opts ...optnewstack.NewSt
171225
}
172226
}
173227

174-
if err != nil {
175-
ptFatalF(t, "failed to create stack: %s", err)
176-
return nil
177-
}
178-
if !stackOptions.SkipDestroy {
179-
t.Cleanup(func() {
180-
t.Helper()
181-
182-
if ptFailed(t) && skipDestroyOnFailure() {
183-
t.Log("Skipping destroy because PULUMITEST_SKIP_DESTROY_ON_FAILURE is set to 'true'.")
184-
writeDestroyScript(t, stack.Workspace().WorkDir(), stackName, env)
185-
return
186-
}
187-
188-
t.Log("destroying stack, to skip this set PULUMITEST_SKIP_DESTROY_ON_FAILURE=true")
189-
_, err := stack.Destroy(pt.ctx)
190-
if err != nil {
191-
ptErrorF(t, "failed to destroy stack: %s", err)
192-
}
193-
err = stack.Workspace().RemoveStack(pt.ctx, stackName, optremove.Force())
194-
if err != nil {
195-
ptErrorF(t, "failed to remove stack: %s", err)
196-
}
197-
})
198-
}
199228
pt.currentStack = &stack
200229
return &stack
201230
}

pulumitest/opttest/opttest.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ func YarnLink(packages ...string) Option {
105105
})
106106
}
107107

108+
// PythonLink specifies packages which should be installed from a local path via `pip install -e` (editable mode).
109+
// Each package path is installed with `pip install -e <path>` on stack creation.
110+
func PythonLink(packagePaths ...string) Option {
111+
return optionFunc(func(o *Options) {
112+
o.PythonLinks = append(o.PythonLinks, packagePaths...)
113+
})
114+
}
115+
108116
// GoModReplacement specifies replacements to be add to the go.mod file when running the program under test.
109117
// Each replacement is added to the go.mod file with `go mod edit -replace <replacement>` on stack creation.
110118
func GoModReplacement(packageSpecifier string, replacementPathElem ...string) Option {
@@ -166,6 +174,7 @@ type Options struct {
166174
Providers map[providers.ProviderName]ProviderConfigUnion
167175
UseAmbientBackend bool
168176
YarnLinks []string
177+
PythonLinks []string
169178
GoModReplacements map[string]string
170179
CustomEnv map[string]string
171180
ExtraWorkspaceOptions []auto.LocalWorkspaceOption
@@ -200,6 +209,7 @@ func Defaults() Option {
200209
o.Providers = make(map[providers.ProviderName]ProviderConfigUnion)
201210
o.UseAmbientBackend = false
202211
o.YarnLinks = []string{}
212+
o.PythonLinks = []string{}
203213
o.GoModReplacements = make(map[string]string)
204214
o.CustomEnv = make(map[string]string)
205215
o.ExtraWorkspaceOptions = []auto.LocalWorkspaceOption{}

pulumitest/opttest/opttest_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package opttest_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/pulumi/providertest/pulumitest/opttest"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestPythonLinkOption(t *testing.T) {
12+
t.Parallel()
13+
14+
opts := opttest.DefaultOptions()
15+
assert.Empty(t, opts.PythonLinks, "expected PythonLinks to be empty by default")
16+
17+
pythonLink := opttest.PythonLink("path/to/sdk")
18+
pythonLink.Apply(opts)
19+
20+
assert.Equal(t, []string{"path/to/sdk"}, opts.PythonLinks, "expected PythonLink to append path")
21+
}
22+
23+
func TestPythonLinkMultiplePackages(t *testing.T) {
24+
t.Parallel()
25+
26+
opts := opttest.DefaultOptions()
27+
28+
pythonLink := opttest.PythonLink("path/to/sdk1", "path/to/sdk2")
29+
pythonLink.Apply(opts)
30+
31+
assert.Equal(t, []string{"path/to/sdk1", "path/to/sdk2"}, opts.PythonLinks,
32+
"expected PythonLink to append multiple paths")
33+
}
34+
35+
func TestPythonLinkAccumulates(t *testing.T) {
36+
t.Parallel()
37+
38+
opts := opttest.DefaultOptions()
39+
40+
pythonLink1 := opttest.PythonLink("path/to/sdk1")
41+
pythonLink1.Apply(opts)
42+
43+
pythonLink2 := opttest.PythonLink("path/to/sdk2")
44+
pythonLink2.Apply(opts)
45+
46+
assert.Equal(t, []string{"path/to/sdk1", "path/to/sdk2"}, opts.PythonLinks,
47+
"expected PythonLinks to accumulate across multiple calls")
48+
}
49+
50+
func TestDefaultsResetsPythonLinks(t *testing.T) {
51+
t.Parallel()
52+
53+
opts := opttest.DefaultOptions()
54+
55+
pythonLink := opttest.PythonLink("path/to/sdk")
56+
pythonLink.Apply(opts)
57+
58+
assert.NotEmpty(t, opts.PythonLinks, "expected PythonLinks to be populated")
59+
60+
defaults := opttest.Defaults()
61+
defaults.Apply(opts)
62+
63+
assert.Empty(t, opts.PythonLinks, "expected Defaults to reset PythonLinks")
64+
}
65+
66+
func TestPythonLinkIntegrationV1(t *testing.T) {
67+
t.Parallel()
68+
69+
// Integration test: verify PythonLink can be used with a real test package (v1)
70+
// This test checks that the option correctly processes package paths
71+
pkgV1Path := filepath.Join("..", "testdata", "python_pkg_v1")
72+
73+
// Verify the test package directory exists
74+
_, err := filepath.Abs(pkgV1Path)
75+
assert.NoError(t, err, "expected to resolve package path v1")
76+
77+
// Create test with PythonLink pointing to v1 package
78+
opts := opttest.DefaultOptions()
79+
pythonLink := opttest.PythonLink(pkgV1Path)
80+
pythonLink.Apply(opts)
81+
82+
// Verify the path was correctly added to options
83+
assert.Equal(t, 1, len(opts.PythonLinks), "expected one Python package path")
84+
assert.True(t, len(opts.PythonLinks[0]) > 0, "expected non-empty package path")
85+
}
86+
87+
func TestPythonLinkIntegrationV2(t *testing.T) {
88+
t.Parallel()
89+
90+
// Integration test: verify PythonLink can be used with a real test package (v2)
91+
pkgV2Path := filepath.Join("..", "testdata", "python_pkg_v2")
92+
93+
// Verify the test package directory exists
94+
_, err := filepath.Abs(pkgV2Path)
95+
assert.NoError(t, err, "expected to resolve package path v2")
96+
97+
// Create test with PythonLink pointing to v2 package
98+
opts := opttest.DefaultOptions()
99+
pythonLink := opttest.PythonLink(pkgV2Path)
100+
pythonLink.Apply(opts)
101+
102+
// Verify the path was correctly added to options
103+
assert.Equal(t, 1, len(opts.PythonLinks), "expected one Python package path")
104+
assert.True(t, len(opts.PythonLinks[0]) > 0, "expected non-empty package path")
105+
}
106+
107+
func TestPythonLinkUpgradePathGeneration(t *testing.T) {
108+
t.Parallel()
109+
110+
// Integration test: verify PythonLink generates correct paths for version upgrades
111+
pkgV1Path := filepath.Join("..", "testdata", "python_pkg_v1")
112+
pkgV2Path := filepath.Join("..", "testdata", "python_pkg_v2")
113+
114+
opts := opttest.DefaultOptions()
115+
116+
// Add v1 package
117+
pythonLinkV1 := opttest.PythonLink(pkgV1Path)
118+
pythonLinkV1.Apply(opts)
119+
assert.Equal(t, 1, len(opts.PythonLinks), "expected one path after v1")
120+
121+
// Add v2 package (simulating version upgrade)
122+
pythonLinkV2 := opttest.PythonLink(pkgV2Path)
123+
pythonLinkV2.Apply(opts)
124+
assert.Equal(t, 2, len(opts.PythonLinks), "expected two paths after adding v2")
125+
126+
// Verify both paths are present
127+
assert.Contains(t, opts.PythonLinks, pkgV1Path, "expected v1 path to be present")
128+
assert.Contains(t, opts.PythonLinks, pkgV2Path, "expected v2 path to be present")
129+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Test package for PythonLink integration tests."""
2+
3+
__version__ = "0.0.1"
4+
5+
6+
def get_version():
7+
"""Return the package version."""
8+
return __version__
9+
10+
11+
def get_message():
12+
"""Return a version-specific message."""
13+
return f"pulumi-test-pkg version {__version__}"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from setuptools import setup, find_packages
2+
3+
setup(
4+
name="pulumi-test-pkg",
5+
version="0.0.1",
6+
packages=find_packages(),
7+
description="Test package for PythonLink integration tests",
8+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Test package for PythonLink integration tests."""
2+
3+
__version__ = "0.0.2"
4+
5+
6+
def get_version():
7+
"""Return the package version."""
8+
return __version__
9+
10+
11+
def get_message():
12+
"""Return a version-specific message."""
13+
return f"pulumi-test-pkg version {__version__}"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from setuptools import setup, find_packages
2+
3+
setup(
4+
name="pulumi-test-pkg",
5+
version="0.0.2",
6+
packages=find_packages(),
7+
description="Test package for PythonLink integration tests",
8+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: python-with-local-pkg
2+
runtime: python
3+
description: Test program for PythonLink integration tests
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Pulumi program that uses the local test package."""
2+
3+
import pulumi_test_pkg
4+
5+
# Import the test package and verify version
6+
version = pulumi_test_pkg.get_version()
7+
message = pulumi_test_pkg.get_message()
8+
9+
# Export the version as a stack output
10+
pulumi.export("package_version", version)
11+
pulumi.export("package_message", message)

0 commit comments

Comments
 (0)