Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The `pulumitest` module is the core testing library for Pulumi programs and prov

**Options System** (`opttest/opttest.go`)
- Functional options pattern via `opttest.Option` interface
- Key options: `AttachProvider`, `AttachProviderServer`, `AttachProviderBinary`, `TestInPlace`, `SkipInstall`, `SkipStackCreate`, `YarnLink`, `GoModReplacement`, `DotNetReference`, `LocalProviderPath`
- Key options: `AttachProvider`, `AttachProviderServer`, `AttachProviderBinary`, `TestInPlace`, `SkipInstall`, `SkipStackCreate`, `YarnLink`, `PythonLink`, `GoModReplacement`, `DotNetReference`, `LocalProviderPath`
- Options are deeply copied to allow independent modification when using `CopyToTempDir()`
- Default passphrase: "correct horse battery staple" for deterministic encryption

Expand Down Expand Up @@ -152,3 +152,31 @@ The library supports multiple ways to configure providers for testing:
- `pulumi install` fails: Check .csproj package versions are available on NuGet
- Build fails with missing types: Verify all project references are correctly added
- Stack creation hangs: Check for `PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK=true` in CI environments

### Python Issues

**PythonLink Path Resolution**
- Relative paths in `PythonLink()` are converted to absolute paths automatically
- Use paths relative to the test working directory
- The test framework resolves paths before passing to `pip install -e`

**Python Environment Detection**
- The library prefers `python3` command, then falls back to `python`
- Uses `python3 -m pip install -e <path>` for installation (falls back to `python` if `python3` is not available)
- Ensure the Python interpreter used matches the one configured in your Pulumi program

**Package Not Found After PythonLink**
- Error: Pulumi program fails with `ModuleNotFoundError` despite `PythonLink()`
- Solution: Verify the test Python environment matches the Pulumi program's Python environment
- Check installed packages: `python3 -m pip list | grep <package-name>`
- Ensure virtual environment is activated before running tests if your program uses one

**Editable Install Issues**
- Error: `error: invalid command 'develop'` or setup.py errors
- Solution: Ensure the package directory has a valid `setup.py` or `pyproject.toml`
- `PythonLink()` expects a package directory, not a Python file

**Common Test Failures**
- `pulumi install` fails: Unrelated to `PythonLink`; check `requirements.txt` in your Pulumi program
- Import errors with local SDK: The editable install creates symlinks; ensure no version conflicts
- Test passes but program fails: Python environments differ between test and Pulumi execution
17 changes: 17 additions & 0 deletions pulumitest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ test := NewPulumiTest(t,
)
```

### Python - Local Package Installation

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).

The local package installation can be specified using the `PythonLink` test option:

```go
NewPulumiTest(t, "test_dir", opttest.PythonLink("../sdk/python"))
```

Multiple packages can be specified:

```go
NewPulumiTest(t, "test_dir",
opttest.PythonLink("../sdk/python", "../other-sdk/python"))
```

## Additional Operations

### Update Source
Expand Down
23 changes: 23 additions & 0 deletions pulumitest/newStack.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,29 @@ func (pt *PulumiTest) NewStack(t PT, stackName string, opts ...optnewstack.NewSt
}
}

if options.PythonLinks != nil && len(options.PythonLinks) > 0 {
// Determine which Python interpreter to use. Try python3 first for better
// compatibility with modern systems, then fall back to python.
pythonCmd := "python"
if _, err := exec.LookPath("python3"); err == nil {
pythonCmd = "python3"
}

for _, pkgPath := range options.PythonLinks {
absPath, err := filepath.Abs(pkgPath)
if err != nil {
ptFatalF(t, "failed to get absolute path for %s: %s", pkgPath, err)
}
cmd := exec.Command(pythonCmd, "-m", "pip", "install", "-e", absPath)
cmd.Dir = pt.workingDir
ptLogF(t, "installing python package: %s", cmd)
out, err := cmd.CombinedOutput()
if err != nil {
ptFatalF(t, "failed to install python package %s: %s\n%s", pkgPath, err, out)
}
}
}

if options.GoModReplacements != nil && len(options.GoModReplacements) > 0 {
orderedReplacements := make([]string, 0, len(options.GoModReplacements))
for old := range options.GoModReplacements {
Expand Down
10 changes: 10 additions & 0 deletions pulumitest/opttest/opttest.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ func YarnLink(packages ...string) Option {
})
}

// PythonLink specifies packages which should be installed from a local path via `pip install -e` (editable mode).
// Each package path is installed with `pip install -e <path>` on stack creation.
func PythonLink(packagePaths ...string) Option {
return optionFunc(func(o *Options) {
o.PythonLinks = append(o.PythonLinks, packagePaths...)
})
}

// GoModReplacement specifies replacements to be add to the go.mod file when running the program under test.
// Each replacement is added to the go.mod file with `go mod edit -replace <replacement>` on stack creation.
func GoModReplacement(packageSpecifier string, replacementPathElem ...string) Option {
Expand Down Expand Up @@ -175,6 +183,7 @@ type Options struct {
Providers map[providers.ProviderName]ProviderConfigUnion
UseAmbientBackend bool
YarnLinks []string
PythonLinks []string
GoModReplacements map[string]string
DotNetReferences map[string]string
CustomEnv map[string]string
Expand Down Expand Up @@ -210,6 +219,7 @@ func Defaults() Option {
o.Providers = make(map[providers.ProviderName]ProviderConfigUnion)
o.UseAmbientBackend = false
o.YarnLinks = []string{}
o.PythonLinks = []string{}
o.GoModReplacements = make(map[string]string)
o.DotNetReferences = make(map[string]string)
o.CustomEnv = make(map[string]string)
Expand Down
129 changes: 129 additions & 0 deletions pulumitest/opttest/opttest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package opttest_test

import (
"path/filepath"
"testing"

"github.com/pulumi/providertest/pulumitest/opttest"
"github.com/stretchr/testify/assert"
)

func TestPythonLinkOption(t *testing.T) {
t.Parallel()

opts := opttest.DefaultOptions()
assert.Empty(t, opts.PythonLinks, "expected PythonLinks to be empty by default")

pythonLink := opttest.PythonLink("path/to/sdk")
pythonLink.Apply(opts)

assert.Equal(t, []string{"path/to/sdk"}, opts.PythonLinks, "expected PythonLink to append path")
}

func TestPythonLinkMultiplePackages(t *testing.T) {
t.Parallel()

opts := opttest.DefaultOptions()

pythonLink := opttest.PythonLink("path/to/sdk1", "path/to/sdk2")
pythonLink.Apply(opts)

assert.Equal(t, []string{"path/to/sdk1", "path/to/sdk2"}, opts.PythonLinks,
"expected PythonLink to append multiple paths")
}

func TestPythonLinkAccumulates(t *testing.T) {
t.Parallel()

opts := opttest.DefaultOptions()

pythonLink1 := opttest.PythonLink("path/to/sdk1")
pythonLink1.Apply(opts)

pythonLink2 := opttest.PythonLink("path/to/sdk2")
pythonLink2.Apply(opts)

assert.Equal(t, []string{"path/to/sdk1", "path/to/sdk2"}, opts.PythonLinks,
"expected PythonLinks to accumulate across multiple calls")
}

func TestDefaultsResetsPythonLinks(t *testing.T) {
t.Parallel()

opts := opttest.DefaultOptions()

pythonLink := opttest.PythonLink("path/to/sdk")
pythonLink.Apply(opts)

assert.NotEmpty(t, opts.PythonLinks, "expected PythonLinks to be populated")

defaults := opttest.Defaults()
defaults.Apply(opts)

assert.Empty(t, opts.PythonLinks, "expected Defaults to reset PythonLinks")
}

func TestPythonLinkIntegrationV1(t *testing.T) {
t.Parallel()

// Integration test: verify PythonLink can be used with a real test package (v1)
// This test checks that the option correctly processes package paths
pkgV1Path := filepath.Join("..", "testdata", "python_pkg_v1")

// Verify the test package directory exists
_, err := filepath.Abs(pkgV1Path)
assert.NoError(t, err, "expected to resolve package path v1")

// Create test with PythonLink pointing to v1 package
opts := opttest.DefaultOptions()
pythonLink := opttest.PythonLink(pkgV1Path)
pythonLink.Apply(opts)

// Verify the path was correctly added to options
assert.Equal(t, 1, len(opts.PythonLinks), "expected one Python package path")
assert.True(t, len(opts.PythonLinks[0]) > 0, "expected non-empty package path")
}

func TestPythonLinkIntegrationV2(t *testing.T) {
t.Parallel()

// Integration test: verify PythonLink can be used with a real test package (v2)
pkgV2Path := filepath.Join("..", "testdata", "python_pkg_v2")

// Verify the test package directory exists
_, err := filepath.Abs(pkgV2Path)
assert.NoError(t, err, "expected to resolve package path v2")

// Create test with PythonLink pointing to v2 package
opts := opttest.DefaultOptions()
pythonLink := opttest.PythonLink(pkgV2Path)
pythonLink.Apply(opts)

// Verify the path was correctly added to options
assert.Equal(t, 1, len(opts.PythonLinks), "expected one Python package path")
assert.True(t, len(opts.PythonLinks[0]) > 0, "expected non-empty package path")
}

func TestPythonLinkUpgradePathGeneration(t *testing.T) {
t.Parallel()

// Integration test: verify PythonLink generates correct paths for version upgrades
pkgV1Path := filepath.Join("..", "testdata", "python_pkg_v1")
pkgV2Path := filepath.Join("..", "testdata", "python_pkg_v2")

opts := opttest.DefaultOptions()

// Add v1 package
pythonLinkV1 := opttest.PythonLink(pkgV1Path)
pythonLinkV1.Apply(opts)
assert.Equal(t, 1, len(opts.PythonLinks), "expected one path after v1")

// Add v2 package (simulating version upgrade)
pythonLinkV2 := opttest.PythonLink(pkgV2Path)
pythonLinkV2.Apply(opts)
assert.Equal(t, 2, len(opts.PythonLinks), "expected two paths after adding v2")

// Verify both paths are present
assert.Contains(t, opts.PythonLinks, pkgV1Path, "expected v1 path to be present")
assert.Contains(t, opts.PythonLinks, pkgV2Path, "expected v2 path to be present")
}
13 changes: 13 additions & 0 deletions pulumitest/testdata/python_pkg_v1/pulumi_test_pkg/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Test package for PythonLink integration tests."""

__version__ = "0.0.1"


def get_version():
"""Return the package version."""
return __version__


def get_message():
"""Return a version-specific message."""
return f"pulumi-test-pkg version {__version__}"
8 changes: 8 additions & 0 deletions pulumitest/testdata/python_pkg_v1/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from setuptools import setup, find_packages

setup(
name="pulumi-test-pkg",
version="0.0.1",
packages=find_packages(),
description="Test package for PythonLink integration tests",
)
13 changes: 13 additions & 0 deletions pulumitest/testdata/python_pkg_v2/pulumi_test_pkg/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Test package for PythonLink integration tests."""

__version__ = "0.0.2"


def get_version():
"""Return the package version."""
return __version__


def get_message():
"""Return a version-specific message."""
return f"pulumi-test-pkg version {__version__}"
8 changes: 8 additions & 0 deletions pulumitest/testdata/python_pkg_v2/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from setuptools import setup, find_packages

setup(
name="pulumi-test-pkg",
version="0.0.2",
packages=find_packages(),
description="Test package for PythonLink integration tests",
)
3 changes: 3 additions & 0 deletions pulumitest/testdata/python_with_local_pkg/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: python-with-local-pkg
runtime: python
description: Test program for PythonLink integration tests
12 changes: 12 additions & 0 deletions pulumitest/testdata/python_with_local_pkg/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Pulumi program that uses the local test package."""

import pulumi
import pulumi_test_pkg

# Import the test package and verify version
version = pulumi_test_pkg.get_version()
message = pulumi_test_pkg.get_message()

# Export the version as a stack output
pulumi.export("package_version", version)
pulumi.export("package_message", message)
1 change: 1 addition & 0 deletions pulumitest/testdata/python_with_local_pkg/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This is intentionally empty - we'll use PythonLink to install the local package
Loading