Skip to content

Commit 91c5db6

Browse files
authored
feat: prioritize pyproject.toml when installing plugin dependencies (#557)
* feat: prioritize `pyproject.toml` when installing plugin dependencies * refactor: rename variable for clarity in SDK version extraction logic * refactor: use local_runtime.ConstructPluginRuntime * refactor: use local_runtime.ConstructPluginRuntime * refactor: fail when uv not found * refactor: use more realistic test plugin data * refactor: don't accept dependencyFileType other than pyprojectTomlFile and requirementsTxtFile * refactor: use type-safe constant to replace hard-coded string * refactor: use factory function of LocalPluginRuntime
1 parent d58d6cb commit 91c5db6

33 files changed

Lines changed: 1036 additions & 20 deletions
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package local_runtime
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
12+
"github.com/langgenius/dify-plugin-daemon/internal/types/app"
13+
"github.com/langgenius/dify-plugin-daemon/pkg/plugin_packager/decoder"
14+
"github.com/langgenius/dify-plugin-daemon/pkg/utils/routine"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestInitPythonEnvironmentWithPyprojectToml(t *testing.T) {
19+
if testing.Short() {
20+
t.Skip("Skipping integration test in short mode")
21+
}
22+
23+
routine.InitPool(1024)
24+
25+
uvPath := findUVPath(t)
26+
pythonPath := findPythonPath(t)
27+
28+
testCases := []struct {
29+
name string
30+
pluginDir string
31+
expectedDependency PythonDependencyFileType
32+
shouldPreferPyproject bool
33+
shouldFail bool
34+
}{
35+
{
36+
name: "plugin with pyproject.toml only",
37+
pluginDir: "plugin-with-pyproject",
38+
expectedDependency: pyprojectTomlFile,
39+
shouldPreferPyproject: true,
40+
shouldFail: false,
41+
},
42+
{
43+
name: "plugin with requirements.txt only",
44+
pluginDir: "plugin-with-requirements",
45+
expectedDependency: requirementsTxtFile,
46+
shouldPreferPyproject: false,
47+
shouldFail: false,
48+
},
49+
{
50+
name: "plugin with both files - pyproject.toml takes priority",
51+
pluginDir: "plugin-with-both",
52+
expectedDependency: pyprojectTomlFile,
53+
shouldPreferPyproject: true,
54+
shouldFail: false,
55+
},
56+
{
57+
name: "plugin without any dependency file - should fail",
58+
pluginDir: "plugin-without-dependencies",
59+
expectedDependency: "",
60+
shouldPreferPyproject: false,
61+
shouldFail: true,
62+
},
63+
}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
tempDir := t.TempDir()
68+
pluginSourceDir := path.Join("testdata", tc.pluginDir)
69+
70+
// Create decoder from source directory
71+
pluginDecoder, err := decoder.NewFSPluginDecoder(pluginSourceDir)
72+
require.NoError(t, err)
73+
74+
appConfig := &app.Config{
75+
PythonInterpreterPath: pythonPath,
76+
UvPath: uvPath,
77+
PythonEnvInitTimeout: 120,
78+
PluginWorkingPath: tempDir,
79+
}
80+
81+
// Use ConstructPluginRuntime to properly initialize all fields
82+
runtime, err := ConstructPluginRuntime(appConfig, pluginDecoder)
83+
require.NoError(t, err)
84+
85+
// Copy plugin files to the computed working path
86+
require.NoError(t, copyDir(pluginSourceDir, runtime.State.WorkingPath))
87+
88+
t.Logf("Testing plugin in: %s", runtime.State.WorkingPath)
89+
90+
if tc.shouldFail {
91+
// Test that file detection fails
92+
fileType, err := runtime.detectDependencyFileType()
93+
require.Error(t, err, "detectDependencyFileType should fail when no dependency file exists")
94+
require.Empty(t, fileType)
95+
require.Contains(t, err.Error(), "neither pyproject.toml nor requirements.txt found")
96+
97+
// Test that InitPythonEnvironment fails gracefully
98+
err = runtime.InitPythonEnvironment()
99+
require.Error(t, err, "InitPythonEnvironment should fail when no dependency file exists")
100+
require.Contains(t, err.Error(), "failed to create virtual environment")
101+
102+
t.Logf("Correctly failed with error: %v", err)
103+
} else {
104+
// Test successful case
105+
fileType, err := runtime.detectDependencyFileType()
106+
require.NoError(t, err)
107+
require.Equal(t, tc.expectedDependency, fileType)
108+
109+
err = runtime.InitPythonEnvironment()
110+
require.NoError(t, err, "InitPythonEnvironment should succeed")
111+
112+
venvPath := path.Join(runtime.State.WorkingPath, ".venv")
113+
require.DirExists(t, venvPath, "Virtual environment should be created")
114+
115+
pythonBinPath := path.Join(venvPath, "bin", "python")
116+
require.FileExists(t, pythonBinPath, "Python binary should exist in venv")
117+
118+
validFlagPath := path.Join(venvPath, "dify", "plugin.json")
119+
require.FileExists(t, validFlagPath, "Valid flag file should exist")
120+
121+
cmd := exec.Command(pythonBinPath, "-c", "import dify_plugin; print('SUCCESS')")
122+
cmd.Dir = runtime.State.WorkingPath
123+
output, err := cmd.CombinedOutput()
124+
if err != nil {
125+
t.Logf("Python import failed. Output: %s", string(output))
126+
}
127+
require.NoError(t, err, "Should be able to import dify_plugin")
128+
require.Contains(t, string(output), "SUCCESS", "dify_plugin should import successfully")
129+
t.Logf("dify_plugin imported successfully")
130+
131+
if tc.shouldPreferPyproject {
132+
lockfilePath := path.Join(runtime.State.WorkingPath, "uv.lock")
133+
t.Logf("Checking for uv.lock at: %s", lockfilePath)
134+
if _, err := os.Stat(lockfilePath); err == nil {
135+
t.Logf("uv.lock exists (uv sync was used)")
136+
} else {
137+
t.Logf("uv.lock does not exist (may not be created in all cases)")
138+
}
139+
}
140+
}
141+
})
142+
}
143+
}
144+
145+
func TestInitPythonEnvironmentErrorHandling(t *testing.T) {
146+
if testing.Short() {
147+
t.Skip("Skipping integration test in short mode")
148+
}
149+
150+
uvPath := findUVPath(t)
151+
pythonPath := findPythonPath(t)
152+
153+
t.Run("fails when no dependency file exists", func(t *testing.T) {
154+
tempDir := t.TempDir()
155+
pluginSourceDir := path.Join("testdata", "plugin-without-dependencies")
156+
157+
pluginDecoder, err := decoder.NewFSPluginDecoder(pluginSourceDir)
158+
require.NoError(t, err)
159+
160+
appConfig := &app.Config{
161+
PythonInterpreterPath: pythonPath,
162+
UvPath: uvPath,
163+
PythonEnvInitTimeout: 120,
164+
PluginWorkingPath: tempDir,
165+
}
166+
167+
runtime, err := ConstructPluginRuntime(appConfig, pluginDecoder)
168+
require.NoError(t, err)
169+
170+
require.NoError(t, copyDir(pluginSourceDir, runtime.State.WorkingPath))
171+
172+
_, err = runtime.detectDependencyFileType()
173+
require.Error(t, err)
174+
require.Contains(t, err.Error(), "neither pyproject.toml nor requirements.txt found")
175+
})
176+
}
177+
178+
func TestCreateVirtualEnvironmentValidation(t *testing.T) {
179+
if testing.Short() {
180+
t.Skip("Skipping integration test in short mode")
181+
}
182+
183+
uvPath := findUVPath(t)
184+
pythonPath := findPythonPath(t)
185+
186+
t.Run("validates pyproject.toml exists", func(t *testing.T) {
187+
tempDir := t.TempDir()
188+
pluginSourceDir := path.Join("testdata", "plugin-with-pyproject")
189+
190+
pluginDecoder, err := decoder.NewFSPluginDecoder(pluginSourceDir)
191+
require.NoError(t, err)
192+
193+
appConfig := &app.Config{
194+
PythonInterpreterPath: pythonPath,
195+
UvPath: uvPath,
196+
PythonEnvInitTimeout: 120,
197+
PluginWorkingPath: tempDir,
198+
}
199+
200+
runtime, err := ConstructPluginRuntime(appConfig, pluginDecoder)
201+
require.NoError(t, err)
202+
203+
require.NoError(t, copyDir(pluginSourceDir, runtime.State.WorkingPath))
204+
205+
venv, err := runtime.createVirtualEnvironment(uvPath)
206+
require.NoError(t, err, "Should create venv when pyproject.toml exists")
207+
require.NotNil(t, venv)
208+
})
209+
210+
t.Run("validates requirements.txt exists", func(t *testing.T) {
211+
tempDir := t.TempDir()
212+
pluginSourceDir := path.Join("testdata", "plugin-with-requirements")
213+
214+
pluginDecoder, err := decoder.NewFSPluginDecoder(pluginSourceDir)
215+
require.NoError(t, err)
216+
217+
appConfig := &app.Config{
218+
PythonInterpreterPath: pythonPath,
219+
UvPath: uvPath,
220+
PythonEnvInitTimeout: 120,
221+
PluginWorkingPath: tempDir,
222+
}
223+
224+
runtime, err := ConstructPluginRuntime(appConfig, pluginDecoder)
225+
require.NoError(t, err)
226+
227+
require.NoError(t, copyDir(pluginSourceDir, runtime.State.WorkingPath))
228+
229+
venv, err := runtime.createVirtualEnvironment(uvPath)
230+
require.NoError(t, err, "Should create venv when requirements.txt exists")
231+
require.NotNil(t, venv)
232+
})
233+
}
234+
235+
func findUVPath(t *testing.T) string {
236+
t.Helper()
237+
238+
uvPath := os.Getenv("UV_PATH")
239+
if uvPath != "" {
240+
return uvPath
241+
}
242+
243+
cmd := exec.Command("which", "uv")
244+
output, err := cmd.Output()
245+
if err != nil {
246+
cmd = exec.Command("python3", "-c", "from uv._find_uv import find_uv_bin; print(find_uv_bin())")
247+
output, err = cmd.Output()
248+
if err != nil {
249+
t.Fatal("UV not found. Install UV to run integration tests: https://docs.astral.sh/uv/")
250+
return ""
251+
}
252+
}
253+
254+
uvPath = strings.TrimSpace(string(output))
255+
if uvPath == "" {
256+
t.Fatal("UV not found. Install UV to run integration tests: https://docs.astral.sh/uv/")
257+
}
258+
259+
t.Logf("Found UV at: %s", uvPath)
260+
return uvPath
261+
}
262+
263+
func findPythonPath(t *testing.T) string {
264+
t.Helper()
265+
266+
pythonPath := os.Getenv("PYTHON_INTERPRETER_PATH")
267+
if pythonPath != "" {
268+
return pythonPath
269+
}
270+
271+
for _, name := range []string{"python3.12", "python3.11", "python3.10", "python3"} {
272+
cmd := exec.Command("which", name)
273+
output, err := cmd.Output()
274+
if err == nil {
275+
pythonPath = strings.TrimSpace(string(output))
276+
if pythonPath != "" {
277+
t.Logf("Found Python at: %s", pythonPath)
278+
return pythonPath
279+
}
280+
}
281+
}
282+
283+
t.Fatal("Python 3 not found. Install Python 3.10+ to run integration tests")
284+
return ""
285+
}
286+
287+
func copyDir(src string, dst string) error {
288+
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
289+
if err != nil {
290+
return err
291+
}
292+
293+
relPath, err := filepath.Rel(src, path)
294+
if err != nil {
295+
return err
296+
}
297+
298+
targetPath := filepath.Join(dst, relPath)
299+
300+
if info.IsDir() {
301+
return os.MkdirAll(targetPath, info.Mode())
302+
}
303+
304+
data, err := os.ReadFile(path)
305+
if err != nil {
306+
return fmt.Errorf("failed to read file %s: %w", path, err)
307+
}
308+
309+
return os.WriteFile(targetPath, data, info.Mode())
310+
})
311+
}

0 commit comments

Comments
 (0)