diff --git a/internal/inspect/detectors/pyshiny.go b/internal/inspect/detectors/pyshiny.go new file mode 100644 index 000000000..485ac46fb --- /dev/null +++ b/internal/inspect/detectors/pyshiny.go @@ -0,0 +1,81 @@ +package detectors + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "fmt" + "regexp" + "strings" + + "github.com/rstudio/connect-client/internal/config" + "github.com/rstudio/connect-client/internal/util" +) + +type pyShinyDetector struct { + inferenceHelper +} + +func NewPyShinyDetector() *pyShinyDetector { + return &pyShinyDetector{ + inferenceHelper: defaultInferenceHelper{}, + } +} + +var shinyExpressImportRE = regexp.MustCompile(`(import\s+shiny.express)|(from\s+shiny.express\s+import)|(from\s+shiny\s+import.*\bexpress\b)`) + +func hasShinyExpressImport(content string) bool { + return shinyExpressImportRE.MatchString(content) +} + +func fileHasShinyExpressImport(path util.Path) (bool, error) { + content, err := path.ReadFile() + if err != nil { + return false, err + } + return hasShinyExpressImport(string(content)), nil +} + +var invalidPythonIdentifierRE = regexp.MustCompile(`(^[0-9]|[^A-Za-z0-9])`) + +func shinyExpressEntrypoint(entrypoint string) string { + module := strings.TrimSuffix(entrypoint, ".py") + + safeEntrypoint := invalidPythonIdentifierRE.ReplaceAllStringFunc(module, func(match string) string { + return fmt.Sprintf("_%x_", int(match[0])) + }) + return "shiny.express.app:" + safeEntrypoint +} + +func (d *pyShinyDetector) InferType(path util.Path) (*config.Config, error) { + entrypoint, entrypointPath, err := d.InferEntrypoint(path, ".py", "main.py", "app.py") + if err != nil { + return nil, err + } + if entrypoint == "" { + // We didn't find a matching filename + return nil, nil + } + matches, err := d.FileHasPythonImports(entrypointPath, []string{"shiny"}) + if err != nil { + return nil, err + } + if !matches { + // Not a PyShiny app + return nil, nil + } + isShinyExpress, err := fileHasShinyExpressImport(entrypointPath) + if err != nil { + return nil, err + } + cfg := config.New() + + if isShinyExpress { + cfg.Entrypoint = shinyExpressEntrypoint(entrypoint) + } else { + cfg.Entrypoint = entrypoint + } + cfg.Type = config.ContentTypePythonShiny + // indicate that Python inspection is needed + cfg.Python = &config.Python{} + return cfg, nil +} diff --git a/internal/inspect/detectors/pyshiny_test.go b/internal/inspect/detectors/pyshiny_test.go new file mode 100644 index 000000000..dfdb95348 --- /dev/null +++ b/internal/inspect/detectors/pyshiny_test.go @@ -0,0 +1,66 @@ +package detectors + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "testing" + + "github.com/rstudio/connect-client/internal/config" + "github.com/rstudio/connect-client/internal/schema" + "github.com/rstudio/connect-client/internal/util" + "github.com/rstudio/connect-client/internal/util/utiltest" + "github.com/spf13/afero" + "github.com/stretchr/testify/suite" +) + +type PyShinySuite struct { + utiltest.Suite +} + +func TestPyShinySuite(t *testing.T) { + suite.Run(t, new(PyShinySuite)) +} + +func (s *PyShinySuite) TestInferType() { + base := util.NewPath("/project", afero.NewMemMapFs()) + err := base.MkdirAll(0777) + s.NoError(err) + + filename := "app.py" + path := base.Join(filename) + err = path.WriteFile([]byte("import shiny\n"), 0600) + s.Nil(err) + + detector := NewPyShinyDetector() + t, err := detector.InferType(base) + s.Nil(err) + s.Equal(&config.Config{ + Schema: schema.ConfigSchemaURL, + Type: config.ContentTypePythonShiny, + Entrypoint: filename, + Validate: true, + Python: &config.Python{}, + }, t) +} + +func (s *PyShinySuite) TestInferTypeShinyExpress() { + base := util.NewPath("/project", afero.NewMemMapFs()) + err := base.MkdirAll(0777) + s.NoError(err) + + filename := "app.py" + path := base.Join(filename) + err = path.WriteFile([]byte("import shiny.express\n"), 0600) + s.Nil(err) + + detector := NewPyShinyDetector() + t, err := detector.InferType(base) + s.Nil(err) + s.Equal(&config.Config{ + Schema: schema.ConfigSchemaURL, + Type: config.ContentTypePythonShiny, + Entrypoint: "shiny.express.app:app", + Validate: true, + Python: &config.Python{}, + }, t) +} diff --git a/internal/inspect/detectors/python.go b/internal/inspect/detectors/python.go index 5f9b82147..08fc0f12c 100644 --- a/internal/inspect/detectors/python.go +++ b/internal/inspect/detectors/python.go @@ -60,12 +60,6 @@ func NewBokehDetector() *PythonAppDetector { }) } -func NewPyShinyDetector() *PythonAppDetector { - return NewPythonAppDetector(config.ContentTypePythonShiny, []string{ - "shiny", - }) -} - func (d *PythonAppDetector) InferType(path util.Path) (*config.Config, error) { entrypoint, entrypointPath, err := d.InferEntrypoint( path, ".py", "main.py", "app.py", "streamlit_app.py", "api.py")