diff --git a/internal/contenttypes/types.go b/internal/contenttypes/types.go index a62e413624..7ae145d8c1 100644 --- a/internal/contenttypes/types.go +++ b/internal/contenttypes/types.go @@ -67,3 +67,14 @@ func (t ContentType) IsAppContent() bool { } return false } + +func (t ContentType) IsRContent() bool { + switch t { + case ContentTypeRPlumber, + ContentTypeRShiny, + ContentTypeRMarkdownShiny, + ContentTypeRMarkdown: + return true + } + return false +} diff --git a/internal/contenttypes/types_test.go b/internal/contenttypes/types_test.go new file mode 100644 index 0000000000..dbc91ab3fe --- /dev/null +++ b/internal/contenttypes/types_test.go @@ -0,0 +1,71 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +package contenttypes + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ContentTypeSuite struct { + suite.Suite +} + +func TestContentTypeSuite(t *testing.T) { + suite.Run(t, new(ContentTypeSuite)) +} + +func (s *ContentTypeSuite) TestIsRContent() { + rContentTypes := []ContentType{ + ContentTypeRPlumber, + ContentTypeRShiny, + ContentTypeRMarkdownShiny, + ContentTypeRMarkdown, + } + for _, ct := range rContentTypes { + s.True(ct.IsRContent(), "Expected %s to be R content", ct) + } + + nonRContentTypes := []ContentType{ + ContentTypeHTML, + ContentTypeJupyterNotebook, + ContentTypePythonShiny, + ContentTypePythonFastAPI, + ContentTypeQuarto, + ContentTypeUnknown, + } + for _, ct := range nonRContentTypes { + s.False(ct.IsRContent(), "Expected %s to not be R content", ct) + } +} + +func (s *ContentTypeSuite) TestIsPythonContent() { + pythonContentTypes := []ContentType{ + ContentTypeJupyterNotebook, + ContentTypeJupyterVoila, + ContentTypePythonBokeh, + ContentTypePythonDash, + ContentTypePythonFastAPI, + ContentTypePythonFlask, + ContentTypePythonGradio, + ContentTypePythonPanel, + ContentTypePythonShiny, + ContentTypePythonStreamlit, + } + for _, ct := range pythonContentTypes { + s.True(ct.IsPythonContent(), "Expected %s to be Python content", ct) + } + + nonPythonContentTypes := []ContentType{ + ContentTypeHTML, + ContentTypeRPlumber, + ContentTypeRShiny, + ContentTypeRMarkdown, + ContentTypeQuarto, + ContentTypeUnknown, + } + for _, ct := range nonPythonContentTypes { + s.False(ct.IsPythonContent(), "Expected %s to not be Python content", ct) + } +} diff --git a/internal/inspect/dependencies/renv/lockfile.go b/internal/inspect/dependencies/renv/lockfile.go index 32cb3553c5..5c50b294cf 100644 --- a/internal/inspect/dependencies/renv/lockfile.go +++ b/internal/inspect/dependencies/renv/lockfile.go @@ -102,6 +102,26 @@ func ValidateModernLockfile(lockfile *Lockfile) error { return nil } +// HasReticulateDependency checks if the project has reticulate as a dependency in renv.lock. +// reticulate is an R package that provides an interface to Python, meaning the project +// requires Python to be available at runtime. +func HasReticulateDependency(base util.AbsolutePath) (bool, error) { + lockfilePath := base.Join("renv.lock") + exists, err := lockfilePath.Exists() + if err != nil { + return false, err + } + if !exists { + return false, nil + } + lockfile, err := ReadLockfile(lockfilePath) + if err != nil { + return false, err + } + _, hasReticulate := lockfile.Packages["reticulate"] + return hasReticulate, nil +} + // isURL detects URLs to distinguish between repository names and repository URLs in renv.lock. // This distinction is critical because the defaultPackageMapper (legacy approach using installed R libraries) // and LockfilePackageMapper (lockfile-only approach) must produce identical output formats regardless diff --git a/internal/inspect/dependencies/renv/sample_lockfile_test.go b/internal/inspect/dependencies/renv/sample_lockfile_test.go index 9879829e40..98d6b40ff5 100644 --- a/internal/inspect/dependencies/renv/sample_lockfile_test.go +++ b/internal/inspect/dependencies/renv/sample_lockfile_test.go @@ -19,6 +19,7 @@ import ( type SampleLockfileSuite struct { utiltest.Suite log logging.Logger + cwd util.AbsolutePath } func TestSampleLockfileSuite(t *testing.T) { @@ -27,6 +28,9 @@ func TestSampleLockfileSuite(t *testing.T) { func (s *SampleLockfileSuite) SetupTest() { s.log = logging.New() + cwd, err := util.Getwd(nil) + s.NoError(err) + s.cwd = cwd } func (s *SampleLockfileSuite) TestSample() { @@ -167,3 +171,86 @@ func (s *SampleLockfileSuite) TestSample() { // This is because the lockfile may contain packages that are not directly referenced // in the manifest.json, such as system packages or tools like renv, packrat, etc. } + +func (s *SampleLockfileSuite) TestHasReticulateDependency_WithReticulate() { + // Create a temporary directory with a lockfile containing reticulate + tempDir := s.cwd.Join("testdata", "reticulate-test") + err := tempDir.MkdirAll(0755) + s.NoError(err) + defer tempDir.RemoveAll() + + // Create a minimal renv.lock with reticulate as a package + lockfileContent := `{ + "R": { + "Version": "4.3.3", + "Repositories": [ + {"Name": "CRAN", "URL": "https://cloud.r-project.org"} + ] + }, + "Packages": { + "reticulate": { + "Package": "reticulate", + "Version": "1.34.0", + "Source": "Repository", + "Repository": "CRAN" + } + } +}` + lockfilePath := tempDir.Join("renv.lock") + err = lockfilePath.WriteFile([]byte(lockfileContent), 0644) + s.NoError(err) + + hasReticulate, err := HasReticulateDependency(tempDir) + s.NoError(err) + s.True(hasReticulate, "Lockfile with reticulate package should have reticulate dependency") +} + +func (s *SampleLockfileSuite) TestHasReticulateDependency_NoLockfile() { + // Get the current directory + cwd, err := util.Getwd(nil) + s.NoError(err) + + // A directory without renv.lock should return false + hasReticulate, err := HasReticulateDependency(cwd) + s.NoError(err) + s.False(hasReticulate, "Directory without renv.lock should not have reticulate dependency") +} + +func (s *SampleLockfileSuite) TestHasReticulateDependency_WithoutReticulate() { + // Create a temporary directory with a lockfile that does NOT contain reticulate + tempDir := s.cwd.Join("testdata", "no-reticulate-test") + err := tempDir.MkdirAll(0755) + s.NoError(err) + defer tempDir.RemoveAll() + + // Create a minimal renv.lock without reticulate + lockfileContent := `{ + "R": { + "Version": "4.3.3", + "Repositories": [ + {"Name": "CRAN", "URL": "https://cloud.r-project.org"} + ] + }, + "Packages": { + "ggplot2": { + "Package": "ggplot2", + "Version": "3.4.0", + "Source": "Repository", + "Repository": "CRAN" + }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.0", + "Source": "Repository", + "Repository": "CRAN" + } + } +}` + lockfilePath := tempDir.Join("renv.lock") + err = lockfilePath.WriteFile([]byte(lockfileContent), 0644) + s.NoError(err) + + hasReticulate, err := HasReticulateDependency(tempDir) + s.NoError(err) + s.False(hasReticulate, "Lockfile without reticulate package should not have reticulate dependency") +} diff --git a/internal/inspect/python.go b/internal/inspect/python.go index 6c9a86a004..fcd03f1633 100644 --- a/internal/inspect/python.go +++ b/internal/inspect/python.go @@ -6,6 +6,7 @@ import ( "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/executor" "github.com/posit-dev/publisher/internal/inspect/dependencies/pydeps" + "github.com/posit-dev/publisher/internal/inspect/dependencies/renv" "github.com/posit-dev/publisher/internal/interpreters" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/types" @@ -19,6 +20,10 @@ type PythonInspector interface { GetPythonInterpreter() interpreters.PythonInterpreter } +// ReticulateChecker is a function type for checking if a project has reticulate as a dependency. +// This allows for dependency injection in tests. +type ReticulateChecker func(base util.AbsolutePath) (bool, error) + type defaultPythonInspector struct { base util.AbsolutePath executor executor.Executor @@ -26,6 +31,7 @@ type defaultPythonInspector struct { scanner pydeps.DependencyScanner pythonInterpreter interpreters.PythonInterpreter log logging.Logger + reticulateChecker ReticulateChecker // optional override for testing } var _ PythonInspector = &defaultPythonInspector{} @@ -151,5 +157,23 @@ func (i *defaultPythonInspector) RequiresPython(cfg *config.Config) (bool, error if err != nil { return false, err } - return exists, nil + if exists { + return true, nil + } + // Check if the R project uses reticulate, which requires Python at runtime + if cfg.Type.IsRContent() { + checker := renv.HasReticulateDependency + if i.reticulateChecker != nil { + checker = i.reticulateChecker + } + hasReticulate, err := checker(i.base) + if err != nil { + return false, err + } + if hasReticulate { + i.log.Info("Detected reticulate dependency, Python is required") + return true, nil + } + } + return false, nil } diff --git a/internal/inspect/python_test.go b/internal/inspect/python_test.go index 9d8d698418..5372c447b6 100644 --- a/internal/inspect/python_test.go +++ b/internal/inspect/python_test.go @@ -3,9 +3,11 @@ package inspect // Copyright (C) 2023 by Posit Software, PBC. import ( + "errors" "testing" "github.com/posit-dev/publisher/internal/config" + "github.com/posit-dev/publisher/internal/contenttypes" "github.com/posit-dev/publisher/internal/executor" "github.com/posit-dev/publisher/internal/inspect/dependencies/pydeps" "github.com/posit-dev/publisher/internal/interpreters" @@ -188,3 +190,106 @@ func (s *PythonSuite) TestRequiresPython() { s.NoError(err) s.Equal(true, result) } + +func (s *PythonSuite) TestRequiresPython_WithReticulate() { + pythonPath := s.cwd.Join("bin", "python3") + pythonPath.Dir().MkdirAll(0777) + pythonPath.WriteFile(nil, 0777) + log := logging.New() + + setupMockPythonInterpreter := func( + base util.AbsolutePath, + pythonExecutableParam util.Path, + log logging.Logger, + cmdExecutorOverride executor.Executor, + pathLookerOverride util.PathLooker, + existsFuncOverride util.ExistsFunc, + ) (interpreters.PythonInterpreter, error) { + i := interpreters.NewMockPythonInterpreter() + i.On("IsPythonExecutableValid").Return(true) + i.On("GetPythonExecutable").Return(pythonPath, nil) + i.On("GetPythonVersion").Return("1.2.3", nil) + return i, nil + } + + i, err := NewPythonInspector(s.cwd, pythonPath.Path, log, setupMockPythonInterpreter, nil) + s.NoError(err) + inspector := i.(*defaultPythonInspector) + + // Test: R content with reticulate dependency should require Python + rContentConfig := &config.Config{ + Type: contenttypes.ContentTypeRShiny, + } + + // Mock reticulate checker to return true + inspector.reticulateChecker = func(base util.AbsolutePath) (bool, error) { + return true, nil + } + + result, err := inspector.RequiresPython(rContentConfig) + s.NoError(err) + s.True(result, "R content with reticulate dependency should require Python") + + // Test: R content without reticulate dependency should not require Python + inspector.reticulateChecker = func(base util.AbsolutePath) (bool, error) { + return false, nil + } + + result, err = inspector.RequiresPython(rContentConfig) + s.NoError(err) + s.False(result, "R content without reticulate dependency should not require Python") + + // Test: Non-R content should not check for reticulate + htmlConfig := &config.Config{ + Type: contenttypes.ContentTypeHTML, + } + checkerCalled := false + inspector.reticulateChecker = func(base util.AbsolutePath) (bool, error) { + checkerCalled = true + return true, nil + } + + result, err = inspector.RequiresPython(htmlConfig) + s.NoError(err) + s.False(result, "HTML content should not require Python") + s.False(checkerCalled, "Reticulate checker should not be called for non-R content") +} + +func (s *PythonSuite) TestRequiresPython_ReticulateCheckError() { + pythonPath := s.cwd.Join("bin", "python3") + pythonPath.Dir().MkdirAll(0777) + pythonPath.WriteFile(nil, 0777) + log := logging.New() + + setupMockPythonInterpreter := func( + base util.AbsolutePath, + pythonExecutableParam util.Path, + log logging.Logger, + cmdExecutorOverride executor.Executor, + pathLookerOverride util.PathLooker, + existsFuncOverride util.ExistsFunc, + ) (interpreters.PythonInterpreter, error) { + i := interpreters.NewMockPythonInterpreter() + i.On("IsPythonExecutableValid").Return(true) + i.On("GetPythonExecutable").Return(pythonPath, nil) + i.On("GetPythonVersion").Return("1.2.3", nil) + return i, nil + } + + i, err := NewPythonInspector(s.cwd, pythonPath.Path, log, setupMockPythonInterpreter, nil) + s.NoError(err) + inspector := i.(*defaultPythonInspector) + + // Inject a mock reticulate checker that returns an error + testErr := errors.New("failed to read renv.lock") + inspector.reticulateChecker = func(base util.AbsolutePath) (bool, error) { + return false, testErr + } + + cfg := &config.Config{ + Type: contenttypes.ContentTypeRShiny, + } + result, err := inspector.RequiresPython(cfg) + s.ErrorIs(err, testErr, "RequiresPython should propagate error from reticulate check") + s.False(result) +}