Skip to content

Commit d6765c9

Browse files
committed
feat: Add EditDependency to upsert SDK dependency versions
Implemented the `EditDependency()` option function (Issue #40) that allows upserts of dependencies for the program under test to specific versions. The feature automatically detects the language/build system and modifies the appropriate dependency files. **Supported Languages:** - Node.js (package.json via npm/yarn) - Python (requirements.txt via pip) - Go (go.mod via `go get`) - .NET/C# (.csproj files via NuGet) - YAML (requires manual configuration) - `pulumitest/opttest/editdependency.go` - Option function and DependencyEdit type - `pulumitest/editdependency.go` - Language detection and editing logic for all supported languages - `pulumitest/editdependency_test.go` - Comprehensive unit tests for all language implementations - `pulumitest/opttest/opttest.go` - Added DependencyEdits field to Options struct - `pulumitest/newStack.go` - Added execution logic and warning when used with LocalProviderPath - `README.md` - Added documentation and usage examples - `CLAUDE.md` - Added feature documentation and implementation details - **Automatic Language Detection**: Detects Go, Node.js, Python, .NET, and YAML based on file presence - **Version Specification**: Sets exact version for packages in appropriate format per language - **Conflict Warning**: Logs warning when EditDependency is used with LocalProviderPath - **Error Handling**: Provides clear error messages when dependency files are not found or editing fails - **Consistent API**: Follows existing Options pattern like YarnLink and GoModReplacement - 15 unit tests covering: - Language detection for all supported languages - Editing existing dependencies - Adding new dependencies - Various dependency file formats (package.json, requirements.txt, .csproj) - Error cases (missing files, invalid inputs) - Passes golangci-lint v1.64.8 with zero new issues - All code follows existing patterns and conventions - Proper error handling with descriptive messages - Usage of strings.EqualFold for case-insensitive string comparison Run tests with: ```bash go test ./pulumitest -v ``` Run linting with: ```bash ~/.local/bin/golangci-lint-versions/golangci-lint run ./... ``` ```go import ( "filepath" "github.com/pulumi/providertest/pulumitest" "github.com/pulumi/providertest/pulumitest/opttest" ) func TestWithSpecificSDKVersion(t *testing.T) { test := pulumitest.NewPulumiTest(t, filepath.Join("path", "to", "program"), opttest.EditDependency("pulumi", "3.50.0")) test.Preview(t) test.Up(t) } ``` Fully backward compatible. The EditDependency feature is optional and doesn't affect existing code. Fixes #40
1 parent fe0a2a7 commit d6765c9

File tree

6 files changed

+805
-7
lines changed

6 files changed

+805
-7
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,37 @@ func TestPulumiProgram(t *testing.T) {
2525

2626
Refer to [the full documentation](./pulumitest/README.md) for a complete walkthrough of the features.
2727

28+
## Editing SDK Dependencies
29+
30+
You can specify a specific version for a dependency in the program under test using the `opttest.EditDependency()` option. This automatically detects the language/build system and modifies the appropriate dependency file:
31+
32+
```go
33+
import (
34+
"filepath"
35+
"github.com/pulumi/providertest/pulumitest"
36+
"github.com/pulumi/providertest/pulumitest/opttest"
37+
)
38+
39+
func TestPulumiProgramWithSpecificVersion(t *testing.T) {
40+
test := pulumitest.NewPulumiTest(t, filepath.Join("path", "to", "program"),
41+
opttest.EditDependency("pulumi", "3.50.0"))
42+
test.Preview(t)
43+
test.Up(t)
44+
}
45+
```
46+
47+
The `EditDependency()` function supports the following languages:
48+
49+
- **Node.js** - Edits `package.json` to set version for npm/yarn packages
50+
- **Python** - Edits `requirements.txt` to set version for pip packages
51+
- **Go** - Uses `go get package@version` to set version in `go.mod`
52+
- **.NET** - Edits `.csproj` files to set NuGet package version
53+
- **YAML** - For YAML-only projects, returns an error (manual configuration required)
54+
55+
Language detection is automatic based on the presence of standard configuration files (`package.json`, `requirements.txt`, `go.mod`, `.csproj`, etc.).
56+
57+
**Note:** When using `EditDependency()` together with `LocalProviderPath()`, be aware that the local provider path will override the version specified in the SDK, which may cause conflicts. The library will log a warning if this combination is detected.
58+
2859
## Attaching In-Process Providers
2960

3061
If your provider implementation is available in the context of your test, the provider can be started in a background goroutine and used within the test using the `opttest.AttachProviderServer`. This avoids needing to build a provider binary before running the test, and allows stepping through from the test to the provider implementation when attaching a debugger.

pulumitest/editdependency.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package pulumitest
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/pulumi/providertest/pulumitest/opttest"
12+
)
13+
14+
// detectLanguage identifies the language/build system from the project directory
15+
func detectLanguage(workingDir string) string {
16+
// Check for Go
17+
if _, err := os.Stat(filepath.Join(workingDir, "go.mod")); err == nil {
18+
return "go"
19+
}
20+
21+
// Check for .NET
22+
if _, err := os.Stat(filepath.Join(workingDir, "Pulumi.yaml")); err == nil {
23+
if hasCsprojFile(workingDir) {
24+
return "dotnet"
25+
}
26+
}
27+
28+
// Check for Python
29+
if _, err := os.Stat(filepath.Join(workingDir, "requirements.txt")); err == nil {
30+
return "python"
31+
}
32+
if _, err := os.Stat(filepath.Join(workingDir, "setup.py")); err == nil {
33+
return "python"
34+
}
35+
if _, err := os.Stat(filepath.Join(workingDir, "Pipfile")); err == nil {
36+
return "python"
37+
}
38+
39+
// Check for Node.js
40+
if _, err := os.Stat(filepath.Join(workingDir, "package.json")); err == nil {
41+
return "nodejs"
42+
}
43+
44+
// Check for YAML-only project (no specific language detected)
45+
if _, err := os.Stat(filepath.Join(workingDir, "Pulumi.yaml")); err == nil {
46+
return "yaml"
47+
}
48+
49+
return "unknown"
50+
}
51+
52+
// hasCsprojFile checks if there's a .csproj file in the directory or subdirectories
53+
func hasCsprojFile(workingDir string) bool {
54+
entries, err := os.ReadDir(workingDir)
55+
if err != nil {
56+
return false
57+
}
58+
for _, entry := range entries {
59+
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".csproj") {
60+
return true
61+
}
62+
}
63+
return false
64+
}
65+
66+
// editDependencyNode edits a dependency in package.json (Node.js/npm/yarn)
67+
func editDependencyNode(workingDir string, packageName string, version string) error {
68+
packageJSONPath := filepath.Join(workingDir, "package.json")
69+
data, err := os.ReadFile(packageJSONPath)
70+
if err != nil {
71+
return fmt.Errorf("failed to read package.json: %w", err)
72+
}
73+
74+
var packageJSON map[string]interface{}
75+
if err := json.Unmarshal(data, &packageJSON); err != nil {
76+
return fmt.Errorf("failed to parse package.json: %w", err)
77+
}
78+
79+
// Try to find and update the dependency in dependencies, devDependencies, or peerDependencies
80+
for _, depType := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} {
81+
if deps, ok := packageJSON[depType].(map[string]interface{}); ok {
82+
if _, exists := deps[packageName]; exists {
83+
deps[packageName] = version
84+
data, err = json.MarshalIndent(packageJSON, "", " ")
85+
if err != nil {
86+
return fmt.Errorf("failed to marshal package.json: %w", err)
87+
}
88+
data = append(data, '\n')
89+
if err := os.WriteFile(packageJSONPath, data, 0644); err != nil {
90+
return fmt.Errorf("failed to write package.json: %w", err)
91+
}
92+
return nil
93+
}
94+
}
95+
}
96+
97+
// If not found in existing dependencies, add to dependencies
98+
if deps, ok := packageJSON["dependencies"].(map[string]interface{}); ok {
99+
deps[packageName] = version
100+
} else {
101+
packageJSON["dependencies"] = map[string]interface{}{packageName: version}
102+
}
103+
104+
data, err = json.MarshalIndent(packageJSON, "", " ")
105+
if err != nil {
106+
return fmt.Errorf("failed to marshal package.json: %w", err)
107+
}
108+
data = append(data, '\n')
109+
if err := os.WriteFile(packageJSONPath, data, 0644); err != nil {
110+
return fmt.Errorf("failed to write package.json: %w", err)
111+
}
112+
return nil
113+
}
114+
115+
// editDependencyPython edits a dependency in Python files (pip/requirements.txt)
116+
func editDependencyPython(workingDir string, packageName string, version string) error {
117+
// Try requirements.txt first
118+
requirementsPath := filepath.Join(workingDir, "requirements.txt")
119+
if _, err := os.Stat(requirementsPath); err == nil {
120+
return updateRequirementsTxt(requirementsPath, packageName, version)
121+
}
122+
123+
// Try setup.py if requirements.txt doesn't exist
124+
setupPyPath := filepath.Join(workingDir, "setup.py")
125+
if _, err := os.Stat(setupPyPath); err == nil {
126+
return fmt.Errorf("editing setup.py is not yet supported; use requirements.txt or pip install")
127+
}
128+
129+
// Try Pipfile
130+
pipfilePath := filepath.Join(workingDir, "Pipfile")
131+
if _, err := os.Stat(pipfilePath); err == nil {
132+
return fmt.Errorf("editing Pipfile is not yet supported; use requirements.txt instead")
133+
}
134+
135+
return fmt.Errorf("no Python dependency file found (requirements.txt, setup.py, or Pipfile)")
136+
}
137+
138+
// updateRequirementsTxt updates a package version in requirements.txt
139+
func updateRequirementsTxt(path string, packageName string, version string) error {
140+
data, err := os.ReadFile(path)
141+
if err != nil {
142+
return fmt.Errorf("failed to read requirements.txt: %w", err)
143+
}
144+
145+
lines := strings.Split(string(data), "\n")
146+
found := false
147+
for i, line := range lines {
148+
line = strings.TrimSpace(line)
149+
// Skip empty lines and comments
150+
if line == "" || strings.HasPrefix(line, "#") {
151+
continue
152+
}
153+
// Check if this line is for our package
154+
parts := strings.FieldsFunc(line, func(r rune) bool { return r == '=' || r == '<' || r == '>' || r == '!' || r == '~' })
155+
if len(parts) > 0 && strings.EqualFold(strings.TrimSpace(parts[0]), packageName) {
156+
lines[i] = fmt.Sprintf("%s==%s", packageName, version)
157+
found = true
158+
break
159+
}
160+
}
161+
162+
if !found {
163+
lines = append(lines, fmt.Sprintf("%s==%s", packageName, version))
164+
}
165+
166+
output := strings.Join(lines, "\n")
167+
if !strings.HasSuffix(output, "\n") {
168+
output += "\n"
169+
}
170+
171+
if err := os.WriteFile(path, []byte(output), 0644); err != nil {
172+
return fmt.Errorf("failed to write requirements.txt: %w", err)
173+
}
174+
return nil
175+
}
176+
177+
// editDependencyGo edits a dependency in go.mod
178+
func editDependencyGo(workingDir string, packageName string, version string) error {
179+
cmd := exec.Command("go", "get", fmt.Sprintf("%s@%s", packageName, version))
180+
cmd.Dir = workingDir
181+
out, err := cmd.CombinedOutput()
182+
if err != nil {
183+
return fmt.Errorf("failed to update go.mod: %v\n%s", err, out)
184+
}
185+
return nil
186+
}
187+
188+
// editDependencyDotNet edits a dependency in .csproj files
189+
func editDependencyDotNet(workingDir string, packageName string, version string) error {
190+
entries, err := os.ReadDir(workingDir)
191+
if err != nil {
192+
return fmt.Errorf("failed to read directory: %w", err)
193+
}
194+
195+
var found bool
196+
for _, entry := range entries {
197+
if entry.IsDir() {
198+
continue
199+
}
200+
if !strings.HasSuffix(entry.Name(), ".csproj") {
201+
continue
202+
}
203+
204+
csprojPath := filepath.Join(workingDir, entry.Name())
205+
if err := updateCsprojFile(csprojPath, packageName, version); err != nil {
206+
return err
207+
}
208+
found = true
209+
}
210+
211+
if !found {
212+
return fmt.Errorf("no .csproj file found in directory")
213+
}
214+
return nil
215+
}
216+
217+
// updateCsprojFile updates package version in a .csproj file
218+
func updateCsprojFile(path string, packageName string, version string) error {
219+
data, err := os.ReadFile(path)
220+
if err != nil {
221+
return fmt.Errorf("failed to read %s: %w", path, err)
222+
}
223+
224+
content := string(data)
225+
oldPattern := fmt.Sprintf("<PackageReference Include=\"%s\"", packageName)
226+
if !strings.Contains(content, oldPattern) {
227+
// Package not found, add it
228+
newPackageRef := fmt.Sprintf("\n <PackageReference Include=\"%s\" Version=\"%s\" />", packageName, version)
229+
// Insert before closing </ItemGroup>
230+
content = strings.Replace(content, "</ItemGroup>", newPackageRef+"\n </ItemGroup>", 1)
231+
} else {
232+
// Update existing package
233+
// Find and replace the version attribute
234+
lines := strings.Split(content, "\n")
235+
for i, line := range lines {
236+
if strings.Contains(line, oldPattern) {
237+
// Extract indentation and replace the line
238+
indent := strings.Index(line, "<")
239+
lines[i] = strings.Repeat(" ", indent) + fmt.Sprintf("<PackageReference Include=\"%s\" Version=\"%s\" />", packageName, version)
240+
break
241+
}
242+
}
243+
content = strings.Join(lines, "\n")
244+
}
245+
246+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
247+
return fmt.Errorf("failed to write %s: %w", path, err)
248+
}
249+
return nil
250+
}
251+
252+
// editDependencies applies all dependency edits for the given language
253+
func editDependencies(t PT, workingDir string, edits []opttest.DependencyEdit) error {
254+
if len(edits) == 0 {
255+
return nil
256+
}
257+
258+
language := detectLanguage(workingDir)
259+
ptLogF(t, "detected language: %s", language)
260+
261+
for _, edit := range edits {
262+
ptLogF(t, "editing dependency: %s=%s (language: %s)", edit.PackageName, edit.Version, language)
263+
switch language {
264+
case "nodejs":
265+
if err := editDependencyNode(workingDir, edit.PackageName, edit.Version); err != nil {
266+
return fmt.Errorf("failed to edit Node.js dependency: %w", err)
267+
}
268+
case "python":
269+
if err := editDependencyPython(workingDir, edit.PackageName, edit.Version); err != nil {
270+
return fmt.Errorf("failed to edit Python dependency: %w", err)
271+
}
272+
case "go":
273+
if err := editDependencyGo(workingDir, edit.PackageName, edit.Version); err != nil {
274+
return fmt.Errorf("failed to edit Go dependency: %w", err)
275+
}
276+
case "dotnet":
277+
if err := editDependencyDotNet(workingDir, edit.PackageName, edit.Version); err != nil {
278+
return fmt.Errorf("failed to edit .NET dependency: %w", err)
279+
}
280+
case "yaml":
281+
// For YAML projects, we can't automatically edit dependencies
282+
// Return a helpful error message
283+
return fmt.Errorf("cannot automatically edit dependencies in YAML-only projects; please configure manually or use a provider-specific mechanism")
284+
default:
285+
return fmt.Errorf("unknown language detected: %s", language)
286+
}
287+
}
288+
289+
return nil
290+
}

0 commit comments

Comments
 (0)