Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions internal/contenttypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
71 changes: 71 additions & 0 deletions internal/contenttypes/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 20 additions & 0 deletions internal/inspect/dependencies/renv/lockfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions internal/inspect/dependencies/renv/sample_lockfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
type SampleLockfileSuite struct {
utiltest.Suite
log logging.Logger
cwd util.AbsolutePath
}

func TestSampleLockfileSuite(t *testing.T) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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")
}
26 changes: 25 additions & 1 deletion internal/inspect/python.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -19,13 +20,18 @@ 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
pathLooker util.PathLooker
scanner pydeps.DependencyScanner
pythonInterpreter interpreters.PythonInterpreter
log logging.Logger
reticulateChecker ReticulateChecker // optional override for testing
}

var _ PythonInspector = &defaultPythonInspector{}
Expand Down Expand Up @@ -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
}
105 changes: 105 additions & 0 deletions internal/inspect/python_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Loading