diff --git a/core/providers/python/python.go b/core/providers/python/python.go index b8b6766fb..0a206ea44 100644 --- a/core/providers/python/python.go +++ b/core/providers/python/python.go @@ -558,3 +558,93 @@ var pythonRuntimeDepRequirements = map[string][]string{ "pydub": {"ffmpeg"}, "pymovie": {"ffmpeg", "qt5-qmake", "qtbase5-dev", "qtbase5-dev-tools", "qttools5-dev-tools", "libqt5core5a", "python3-pyqt5"}, } + +type PyProjectTOML struct { + Project struct { + Dependencies []string `toml:"dependencies"` + } `toml:"project"` + Tool struct { + Poetry struct { + Dependencies map[string]any `toml:"dependencies"` + } `toml:"poetry"` + } `toml:"tool"` +} + +type Pipfile struct { + Packages map[string]any `toml:"packages"` + DevPackages map[string]any `toml:"dev-packages"` +} + +func extractPackageName(dep string) string { + if idx := strings.Index(dep, " ;"); idx != -1 { + dep = dep[:idx] + } + + versionSpecifiers := []string{">=", "<=", "==", "~=", "!=", ">", "<", "@"} + + result := dep + for _, spec := range versionSpecifiers { + if idx := strings.Index(dep, spec); idx != -1 { + result = dep[:idx] + break + } + } + + return strings.TrimSpace(result) +} + +func normalizeDep(dep string) string { + packageName := extractPackageName(dep) + return strings.ToLower(packageName) +} + +func (p *PythonProvider) HasProductionDependency(ctx *generate.GenerateContext, dep string) bool { + normalizedDep := normalizeDep(dep) + + if contents, err := ctx.App.ReadFile("requirements.txt"); err == nil { + for _, line := range strings.Split(contents, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if normalizeDep(line) == normalizedDep { + return true + } + } + } + + if ctx.App.HasFile("pyproject.toml") { + var pyproject PyProjectTOML + if err := ctx.App.ReadTOML("pyproject.toml", &pyproject); err == nil { + if p.hasPoetry(ctx) { + for depName := range pyproject.Tool.Poetry.Dependencies { + if depName == "python" { + continue + } + if normalizeDep(depName) == normalizedDep { + return true + } + } + } else { + for _, depSpec := range pyproject.Project.Dependencies { + if normalizeDep(depSpec) == normalizedDep { + return true + } + } + } + } + } + + if ctx.App.HasFile("Pipfile") { + var pipfile Pipfile + if err := ctx.App.ReadTOML("Pipfile", &pipfile); err == nil { + for depName := range pipfile.Packages { + if normalizeDep(depName) == normalizedDep { + return true + } + } + } + } + + return false +} diff --git a/core/providers/python/python_test.go b/core/providers/python/python_test.go index cb620fbb7..3deb0440c 100644 --- a/core/providers/python/python_test.go +++ b/core/providers/python/python_test.go @@ -132,3 +132,160 @@ func TestUsesPostgres(t *testing.T) { }) } } + +func TestExtractPackageName(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "simple package name", + input: "flask", + want: "flask", + }, + { + name: "package with >= version", + input: "flask>=3.0", + want: "flask", + }, + { + name: "package with == version", + input: "django==4.2.0", + want: "django", + }, + { + name: "package with extras", + input: "psycopg[binary]", + want: "psycopg[binary]", + }, + { + name: "package with extras and version", + input: "psycopg[binary]>=3.2", + want: "psycopg[binary]", + }, + { + name: "package with ~= version", + input: "requests~=2.31.0", + want: "requests", + }, + { + name: "package with @ URL", + input: "mypackage @ git+https://github.com/user/repo.git", + want: "mypackage", + }, + { + name: "package with environment marker", + input: "typing-extensions ; python_version < '3.8'", + want: "typing-extensions", + }, + { + name: "package with spaces", + input: " numpy >= 1.20 ", + want: "numpy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPackageName(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestNormalizeDep(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "lowercase simple package", + input: "flask", + want: "flask", + }, + { + name: "uppercase package", + input: "Flask", + want: "flask", + }, + { + name: "mixed case with version", + input: "Django>=4.2", + want: "django", + }, + { + name: "package with extras", + input: "psycopg[binary]", + want: "psycopg[binary]", + }, + { + name: "uppercase with extras and version", + input: "Psycopg[Binary]>=3.2", + want: "psycopg[binary]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeDep(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestHasProductionDependency(t *testing.T) { + tests := []struct { + name string + path string + dep string + want bool + }{ + { + name: "pip - flask present", + path: "../../../examples/python-flask", + dep: "flask", + want: true, + }, + { + name: "pip - flask case insensitive", + path: "../../../examples/python-flask", + dep: "Flask", + want: true, + }, + { + name: "poetry - flask present", + path: "../../../examples/python-poetry", + dep: "flask", + want: true, + }, + { + name: "uv - flask present", + path: "../../../examples/python-uv", + dep: "flask", + want: true, + }, + { + name: "psycopg with extras", + path: "../../../examples/python-psycopg-binary", + dep: "psycopg[binary]", + want: true, + }, + { + name: "django present", + path: "../../../examples/python-django", + dep: "django", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := testingUtils.CreateGenerateContext(t, tt.path) + provider := PythonProvider{} + got := provider.HasProductionDependency(ctx, tt.dep) + require.Equal(t, tt.want, got) + }) + } +}