Skip to content

Commit

Permalink
Merge pull request #894 from posit-dev/mm-quarto-website-init
Browse files Browse the repository at this point in the history
Add quarto website inspection
  • Loading branch information
mmarchetti authored Jan 30, 2024
2 parents 7cfc2a6 + ffbe87e commit 7bb7316
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 132 deletions.
11 changes: 6 additions & 5 deletions internal/environment/python.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/rstudio/connect-client/internal/config"
"github.com/rstudio/connect-client/internal/executor"
"github.com/rstudio/connect-client/internal/logging"
"github.com/rstudio/connect-client/internal/util"
)
Expand All @@ -17,7 +18,7 @@ type PythonInspector interface {
}

type defaultPythonInspector struct {
executor util.Executor
executor executor.Executor
pathLooker util.PathLooker
base util.Path
pythonPath util.Path
Expand All @@ -28,7 +29,7 @@ var _ PythonInspector = &defaultPythonInspector{}

func NewPythonInspector(base util.Path, pythonPath util.Path, log logging.Logger) PythonInspector {
return &defaultPythonInspector{
executor: util.NewExecutor(),
executor: executor.NewExecutor(),
pathLooker: util.NewPathLooker(),
base: base,
pythonPath: pythonPath,
Expand Down Expand Up @@ -61,7 +62,7 @@ func (i *defaultPythonInspector) InspectPython() (*config.Python, error) {

func (i *defaultPythonInspector) validatePythonExecutable(pythonExecutable string) error {
args := []string{"--version"}
_, err := i.executor.RunCommand(pythonExecutable, args)
_, err := i.executor.RunCommand(pythonExecutable, args, i.log)
if err != nil {
return fmt.Errorf("could not run python executable '%s': %w", pythonExecutable, err)
}
Expand Down Expand Up @@ -115,7 +116,7 @@ func (i *defaultPythonInspector) getPythonVersion() (string, error) {
`-c`, // execute the next argument as python code
`import sys; v = sys.version_info; print("%d.%d.%d" % (v[0], v[1], v[2]))`,
}
output, err := i.executor.RunCommand(pythonExecutable, args)
output, err := i.executor.RunCommand(pythonExecutable, args, i.log)
if err != nil {
return "", err
}
Expand All @@ -142,7 +143,7 @@ func (i *defaultPythonInspector) ensurePythonRequirementsFile() (string, error)
source := fmt.Sprintf("%s -m pip freeze", pythonExecutable)
i.log.Info("Using Python packages", "source", source)
args := []string{"-m", "pip", "freeze"}
out, err := i.executor.RunCommand(pythonExecutable, args)
out, err := i.executor.RunCommand(pythonExecutable, args, i.log)
if err != nil {
return "", err
}
Expand Down
67 changes: 17 additions & 50 deletions internal/environment/python_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"testing"

"github.com/rstudio/connect-client/internal/executor/executortest"
"github.com/rstudio/connect-client/internal/logging"
"github.com/rstudio/connect-client/internal/util"
"github.com/rstudio/connect-client/internal/util/utiltest"
Expand All @@ -26,24 +27,6 @@ func TestPythonSuite(t *testing.T) {
suite.Run(t, new(PythonSuite))
}

type MockPythonExecutor struct {
mock.Mock
}

func (m *MockPythonExecutor) RunCommand(pythonExecutable string, callArgs []string) ([]byte, error) {
args := m.Called(pythonExecutable, callArgs)
data := args.Get(0)
if data == nil {
return nil, args.Error(1)
} else {
return data.([]byte), args.Error(1)
}
}

func NewMockPythonExecutor() *MockPythonExecutor {
return &MockPythonExecutor{}
}

func (s *PythonSuite) SetupTest() {
cwd, err := util.Getwd(afero.NewMemMapFs())
s.NoError(err)
Expand Down Expand Up @@ -71,8 +54,8 @@ func (s *PythonSuite) TestGetPythonVersionFromExecutable() {
i := NewPythonInspector(util.Path{}, pythonPath, log)
inspector := i.(*defaultPythonInspector)

executor := NewMockPythonExecutor()
executor.On("RunCommand", pythonPath.String(), mock.Anything).Return([]byte("3.10.4"), nil)
executor := executortest.NewMockExecutor()
executor.On("RunCommand", pythonPath.String(), mock.Anything, mock.Anything).Return([]byte("3.10.4"), nil)
inspector.executor = executor
version, err := inspector.getPythonVersion()
s.NoError(err)
Expand All @@ -88,9 +71,9 @@ func (s *PythonSuite) TestGetPythonVersionFromExecutableErr() {
i := NewPythonInspector(base, pythonPath, log)
inspector := i.(*defaultPythonInspector)

executor := NewMockPythonExecutor()
executor := executortest.NewMockExecutor()
testError := errors.New("test error from RunCommand")
executor.On("RunCommand", pythonPath.String(), mock.Anything).Return(nil, testError)
executor.On("RunCommand", pythonPath.String(), mock.Anything, mock.Anything).Return(nil, testError)
inspector.executor = executor
version, err := inspector.getPythonVersion()
s.NotNil(err)
Expand All @@ -103,8 +86,8 @@ func (s *PythonSuite) TestGetPythonVersionFromPATH() {
i := NewPythonInspector(util.Path{}, util.Path{}, log)
inspector := i.(*defaultPythonInspector)

executor := NewMockPythonExecutor()
executor.On("RunCommand", mock.Anything, mock.Anything).Return([]byte("3.10.4"), nil)
executor := executortest.NewMockExecutor()
executor.On("RunCommand", mock.Anything, mock.Anything, mock.Anything).Return([]byte("3.10.4"), nil)
inspector.executor = executor
version, err := inspector.getPythonVersion()
s.NoError(err)
Expand All @@ -129,28 +112,12 @@ func (s *PythonSuite) TestGetPythonVersionFromRealDefaultPython() {
s.True(strings.HasPrefix(version, "3."))
}

type mockPythonExecutor struct {
mock.Mock
}

var _ util.Executor = &mockPythonExecutor{}

func (m *mockPythonExecutor) RunCommand(pythonExecutable string, args []string) ([]byte, error) {
mockArgs := m.Called(pythonExecutable, args)
out := mockArgs.Get(0)
if out != nil {
return out.([]byte), mockArgs.Error(1)
} else {
return nil, mockArgs.Error(1)
}
}

func (s *PythonSuite) TestGetPythonExecutableFallbackPython() {
// python3 does not exist
// python exists and is runnable
log := logging.New()
executor := &mockPythonExecutor{}
executor.On("RunCommand", "/some/python", mock.Anything).Return(nil, nil)
executor := executortest.NewMockExecutor()
executor.On("RunCommand", "/some/python", mock.Anything, mock.Anything).Return(nil, nil)
i := &defaultPythonInspector{
executor: executor,
log: log,
Expand All @@ -169,10 +136,10 @@ func (s *PythonSuite) TestGetPythonExecutablePython3NotRunnable() {
// python3 exists but is not runnable
// python exists and is runnable
log := logging.New()
executor := &mockPythonExecutor{}
executor := executortest.NewMockExecutor()
testError := errors.New("exit status 9009")
executor.On("RunCommand", "/some/python3", mock.Anything).Return(nil, testError)
executor.On("RunCommand", "/some/python", mock.Anything).Return(nil, nil)
executor.On("RunCommand", "/some/python3", mock.Anything, mock.Anything).Return(nil, testError)
executor.On("RunCommand", "/some/python", mock.Anything, mock.Anything).Return(nil, nil)

i := &defaultPythonInspector{
executor: executor,
Expand All @@ -192,10 +159,10 @@ func (s *PythonSuite) TestGetPythonExecutableNoRunnablePython() {
// python3 exists but is not runnable
// python exists but is not runnable
log := logging.New()
executor := &mockPythonExecutor{}
executor := executortest.NewMockExecutor()
testError := errors.New("exit status 9009")
executor.On("RunCommand", "/some/python3", mock.Anything).Return(nil, testError)
executor.On("RunCommand", "/some/python", mock.Anything).Return(nil, testError)
executor.On("RunCommand", "/some/python3", mock.Anything, mock.Anything).Return(nil, testError)
executor.On("RunCommand", "/some/python", mock.Anything, mock.Anything).Return(nil, testError)

i := &defaultPythonInspector{
executor: executor,
Expand Down Expand Up @@ -256,9 +223,9 @@ func (s *PythonSuite) TestEnsurePythonRequirementsFileFromExecutable() {
i := NewPythonInspector(s.cwd, pythonPath, log)
inspector := i.(*defaultPythonInspector)

executor := NewMockPythonExecutor()
executor := executortest.NewMockExecutor()
freezeOutput := []byte("numpy\npandas\n")
executor.On("RunCommand", pythonPath.String(), mock.Anything).Return(freezeOutput, nil)
executor.On("RunCommand", pythonPath.String(), mock.Anything, mock.Anything).Return(freezeOutput, nil)
inspector.executor = executor

filename, err := inspector.ensurePythonRequirementsFile()
Expand Down
15 changes: 12 additions & 3 deletions internal/util/executor.go → internal/executor/executor.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package util
package executor

// Copyright (C) 2023 by Posit Software, PBC.

import (
"bytes"
"os"
"os/exec"
"strings"

"github.com/rstudio/connect-client/internal/logging"
)

type Executor interface {
RunCommand(pythonExecutable string, args []string) ([]byte, error)
RunCommand(pythonExecutable string, args []string, log logging.Logger) ([]byte, error)
}

type defaultExecutor struct{}
Expand All @@ -19,12 +23,17 @@ func NewExecutor() *defaultExecutor {
return &defaultExecutor{}
}

func (e *defaultExecutor) RunCommand(executable string, args []string) ([]byte, error) {
func (e *defaultExecutor) RunCommand(executable string, args []string, log logging.Logger) ([]byte, error) {
log.Info("Running command", "cmd", executable, "args", strings.Join(args, " "))
cmd := exec.Command(executable, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
stderrBuf := new(bytes.Buffer)
cmd.Stderr = stderrBuf
err := cmd.Run()
if err != nil {
log.Error("Error running command", "command", executable, "error", err.Error())
os.Stderr.Write(stderrBuf.Bytes())
return nil, err
}
return stdout.Bytes(), nil
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package utiltest
package executortest

// Copyright (C) 2023 by Posit Software, PBC.

import (
"github.com/rstudio/connect-client/internal/logging"
"github.com/stretchr/testify/mock"
)

Expand All @@ -14,8 +15,8 @@ func NewMockExecutor() *MockExecutor {
return &MockExecutor{}
}

func (m *MockExecutor) RunCommand(executable string, argv []string) ([]byte, error) {
args := m.Called(executable, argv)
func (m *MockExecutor) RunCommand(executable string, argv []string, log logging.Logger) ([]byte, error) {
args := m.Called(executable, argv, log)
out := args.Get(0)
if out == nil {
return nil, args.Error(1)
Expand Down
77 changes: 59 additions & 18 deletions internal/inspect/quarto.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ import (
"encoding/json"
"errors"
"fmt"
"path"
"slices"
"strings"

"github.com/rstudio/connect-client/internal/config"
"github.com/rstudio/connect-client/internal/executor"
"github.com/rstudio/connect-client/internal/logging"
"github.com/rstudio/connect-client/internal/util"
)

type QuartoDetector struct {
inferenceHelper
executor util.Executor
executor executor.Executor
log logging.Logger
}

func NewQuartoDetector() *QuartoDetector {
return &QuartoDetector{
inferenceHelper: defaultInferenceHelper{},
executor: util.NewExecutor(),
executor: executor.NewExecutor(),
log: logging.New(),
}
}

Expand All @@ -35,17 +40,23 @@ type quartoInspectOutput struct {
} `json:"quarto"`
Config struct {
Project struct {
Title string `json:"title"`
PreRender string `json:"pre-render"`
PostRender string `json:"post-render"`
Title string `json:"title"`
PreRender []string `json:"pre-render"`
PostRender []string `json:"post-render"`
} `json:"project"`
Website struct {
Title string `json:"title"`
} `json:"website"`
} `json:"config"`
Engines []string `json:"engines"`
Files struct {
Input []string `json:"input"`
} `json:"files"`
}

func (d *QuartoDetector) quartoInspect(path util.Path) (*quartoInspectOutput, error) {
args := []string{"inspect", path.String()}
out, err := d.executor.RunCommand("quarto", args)
out, err := d.executor.RunCommand("quarto", args, d.log)
if err != nil {
return nil, fmt.Errorf("quarto inspect failed: %w", err)
}
Expand All @@ -58,32 +69,62 @@ func (d *QuartoDetector) quartoInspect(path util.Path) (*quartoInspectOutput, er
}

func (d *QuartoDetector) needsPython(inspectOutput *quartoInspectOutput) bool {
return slices.Contains(inspectOutput.Engines, "jupyter") ||
strings.HasSuffix(inspectOutput.Config.Project.PreRender, ".py") ||
strings.HasSuffix(inspectOutput.Config.Project.PostRender, ".py")
if slices.Contains(inspectOutput.Engines, "jupyter") {
return true
}
for _, script := range inspectOutput.Config.Project.PreRender {
if strings.HasSuffix(script, ".py") {
return true
}
}
for _, script := range inspectOutput.Config.Project.PostRender {
if strings.HasSuffix(script, ".py") {
return true
}
}
return false
}

func (d *QuartoDetector) hasQuartoFile(path util.Path) (bool, error) {
files, err := path.Glob("*.qmd")
if err != nil {
return false, err
}
if len(files) > 0 {
return true, nil
}
return false, nil
}

func (d *QuartoDetector) InferType(path util.Path) (*config.Config, error) {
// Quarto default file is based on the project directory name
defaultEntrypoint := path.Base() + ".qmd"
entrypoint, _, err := d.InferEntrypoint(path, ".qmd", defaultEntrypoint)
func (d *QuartoDetector) InferType(base util.Path) (*config.Config, error) {
haveQuartoFile, err := d.hasQuartoFile(base)
if err != nil {
return nil, err
}
if entrypoint == "" {
if !haveQuartoFile {
return nil, nil
}
inspectOutput, err := d.quartoInspect(path)
inspectOutput, err := d.quartoInspect(base)
if err != nil {
return nil, err
// Maybe this isn't really a quarto project, or maybe the user doesn't have quarto.
// We log this error and return nil so other inspectors can have a shot at it.
d.log.Warn("quarto inspect failed", "error", err)
return nil, nil
}
if slices.Contains(inspectOutput.Engines, "knitr") {
return nil, errNoQuartoKnitrSupport
}
if len(inspectOutput.Files.Input) == 0 {
return nil, nil
}
cfg := config.New()
cfg.Type = config.ContentTypeQuarto
cfg.Entrypoint = entrypoint
cfg.Title = inspectOutput.Config.Project.Title
cfg.Entrypoint = path.Base(inspectOutput.Files.Input[0])

cfg.Title = inspectOutput.Config.Website.Title
if cfg.Title == "" {
cfg.Title = inspectOutput.Config.Project.Title
}

cfg.Quarto = &config.Quarto{
Version: inspectOutput.Quarto.Version,
Expand Down
Loading

0 comments on commit 7bb7316

Please sign in to comment.