Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(planner/python): Implement streamlit support #173

Merged
merged 10 commits into from
Nov 7, 2023
26 changes: 25 additions & 1 deletion internal/python/__snapshots__/plan_test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ RUN pip install uvicorn
---

[TestDetermineInstallCmd_Snapshot/unknown-none - 1]
echo "skip install"
RUN echo "skip install"
---

[TestDetermineStartCmd_Snapshot/pipenv-with-wsgi - 1]
Expand Down Expand Up @@ -203,3 +203,27 @@ RUN pipenv install gunicorn
COPY Pipfile* .
RUN pipenv install
---

[TestDetermineInstallCmd_Snapshot/pipenv-with-streamlit-entry - 1]
RUN pip install pipenv
RUN pipenv install streamlit
COPY Pipfile* .
RUN pipenv install
---

[TestDetermineInstallCmd_Snapshot/pip-with-streamlit-entry - 1]
RUN pip install streamlit
COPY requirements.txt* .
RUN pip install -r requirements.txt
---

[TestDetermineInstallCmd_Snapshot/poetry-with-streamlit-entry - 1]
RUN pip install poetry
RUN poetry add streamlit
COPY poetry.lock* pyproject.toml* .
RUN poetry install
---

[TestDetermineInstallCmd_Snapshot/unknown-with-streamlit-entry - 1]
RUN pip install streamlit
---
4 changes: 2 additions & 2 deletions internal/python/identify.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ func (i *identify) PlanType() types.PlanType {
func (i *identify) Match(fs afero.Fs) bool {
return utils.HasFile(
fs,
"app.py", "main.py", "app.py", "manage.py", "requirements.txt",
"app.py", "main.py", "app.py", "manage.py", "requirements.txt", "streamlit_app.py",
)
}

func (i *identify) PlanMeta(options plan.NewPlannerOptions) types.PlanMeta {
return GetMeta(GetMetaOptions{Src: options.Source})
return GetMeta(GetMetaOptions{Src: options.Source, Config: options.Config})
}

var _ plan.Identifier = (*identify)(nil)
74 changes: 70 additions & 4 deletions internal/python/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package python

import (
"bytes"
"errors"
"fmt"
"log"
Expand All @@ -11,19 +12,29 @@ import (

"github.com/moznion/go-optional"
"github.com/spf13/afero"
"github.com/spf13/cast"
"github.com/zeabur/zbpack/internal/utils"
"github.com/zeabur/zbpack/pkg/plan"
"github.com/zeabur/zbpack/pkg/types"
)

type pythonPlanContext struct {
Src afero.Fs
Config plan.ImmutableProjectConfiguration
PackageManager optional.Option[types.PackageManager]
Framework optional.Option[types.PythonFramework]
Entry optional.Option[string]
Wsgi optional.Option[string]
Static optional.Option[StaticInfo]
StreamlitEntry optional.Option[string]
}

const (
// ConfigStreamlitEntry is the key for specifying the streamlit entry explicitly
// in the project configuration.
ConfigStreamlitEntry = "streamlit.entry"
)

// DetermineFramework determines the framework of the Python project.
func DetermineFramework(ctx *pythonPlanContext) types.PythonFramework {
src := ctx.Src
Expand Down Expand Up @@ -447,6 +458,11 @@ func determineInstallCmd(ctx *pythonPlanContext) string {
commands = append(commands, "RUN pipenv install gunicorn")
}
}

if determineStreamlitEntry(ctx) != "" {
commands = append(commands, "RUN pipenv install streamlit")
}

commands = append(commands, "COPY Pipfile* .", "RUN pipenv install")
case types.PythonPackageManagerPoetry:
commands = append(commands, "RUN pip install poetry")
Expand All @@ -458,16 +474,27 @@ func determineInstallCmd(ctx *pythonPlanContext) string {
commands = append(commands, "RUN poetry add gunicorn")
}
}

if determineStreamlitEntry(ctx) != "" {
commands = append(commands, "RUN poetry add streamlit")
}

commands = append(commands, "COPY poetry.lock* pyproject.toml* .", "RUN poetry install")
case types.PythonPackageManagerPdm:
commands = append(commands, "COPY pdm.lock* pyproject.toml* .", "RUN pip install pdm")

if wsgi != "" {
if framework == types.PythonFrameworkFastapi {
commands = append(commands, "RUN pdm add uvicorn")
} else {
commands = append(commands, "RUN pdm add gunicorn")
}
}

if determineStreamlitEntry(ctx) != "" {
commands = append(commands, "RUN pdm add streamlit")
}

commands = append(commands, "RUN pdm install")
case types.PythonPackageManagerPip:
if wsgi != "" {
Expand All @@ -477,6 +504,11 @@ func determineInstallCmd(ctx *pythonPlanContext) string {
commands = append(commands, "RUN pip install gunicorn")
}
}

if determineStreamlitEntry(ctx) != "" {
commands = append(commands, "RUN pip install streamlit")
}

commands = append(commands, "COPY requirements.txt* .", "RUN pip install -r requirements.txt")
default:
if wsgi != "" {
Expand All @@ -486,13 +518,17 @@ func determineInstallCmd(ctx *pythonPlanContext) string {
commands = append(commands, "RUN pip install gunicorn")
}
}

if determineStreamlitEntry(ctx) != "" {
commands = append(commands, "RUN pip install streamlit")
}
}

command := strings.Join(commands, "\n")
if command != "" {
return command
}
return "echo \"skip install\""
return "RUN echo \"skip install\""
}

func determineAptDependencies(ctx *pythonPlanContext) []string {
Expand Down Expand Up @@ -545,7 +581,9 @@ func determineStartCmd(ctx *pythonPlanContext) string {
commandSegment = append(commandSegment, "pdm run")
}

if wsgi != "" {
if streamlitEntry := determineStreamlitEntry(ctx); streamlitEntry != "" {
commandSegment = append(commandSegment, "streamlit run", streamlitEntry, "--server.port=8080", "--server.address=0.0.0.0")
} else if wsgi != "" {
wsgilistenedPort := "8080"

// The WSGI application should listen at 8000
Expand Down Expand Up @@ -633,17 +671,45 @@ func determineBuildCmd(ctx *pythonPlanContext) string {
return ""
}

func determineStreamlitEntry(ctx *pythonPlanContext) string {
src := ctx.Src
config := ctx.Config
se := &ctx.StreamlitEntry

if entry, err := se.Take(); err == nil {
return entry
}

if streamlitEntry := plan.Cast(config.Get(ConfigStreamlitEntry), cast.ToStringE); streamlitEntry.IsSome() {
*se = optional.Some(streamlitEntry.Unwrap())
return se.Unwrap()
}

for _, file := range []string{"app.py", "main.py", "streamlit_app.py"} {
content, err := afero.ReadFile(src, file)
if err == nil && bytes.Contains(content, []byte("import streamlit")) {
*se = optional.Some(file)
return se.Unwrap()
}
}

*se = optional.Some("")
return se.Unwrap()
}

// GetMetaOptions is the options for GetMeta.
type GetMetaOptions struct {
Src afero.Fs
Src afero.Fs
Config plan.ImmutableProjectConfiguration
}

// GetMeta returns the metadata of a Python project.
func GetMeta(opt GetMetaOptions) types.PlanMeta {
meta := types.PlanMeta{}

ctx := &pythonPlanContext{
Src: opt.Src,
Src: opt.Src,
Config: opt.Config,
}

pm := DeterminePackageManager(ctx)
Expand Down
94 changes: 94 additions & 0 deletions internal/python/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/moznion/go-optional"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/zeabur/zbpack/pkg/plan"
"github.com/zeabur/zbpack/pkg/types"
)

Expand Down Expand Up @@ -189,6 +190,7 @@ func TestDetermineInstallCmd_Snapshot(t *testing.T) {
WithStaticDjango = "with-static-django"
WithStaticNginx = "with-static-nginx"
WithStaticNginxDjango = "with-static-nginx-django"
WithStreamlitEntry = "with-streamlit-entry"
None = "none"
)

Expand All @@ -205,13 +207,19 @@ func TestDetermineInstallCmd_Snapshot(t *testing.T) {
WithStaticNginx,
WithStaticDjango,
WithStaticNginxDjango,
WithStreamlitEntry,
None,
} {
mode := mode
t.Run(string(pm)+"-"+mode, func(t *testing.T) {
t.Parallel()

fs := afero.NewMemMapFs()
config := plan.NewProjectConfigurationFromFs(fs, "")

ctx := pythonPlanContext{
Src: fs,
Config: config,
PackageManager: optional.Some(pm),
}

Expand Down Expand Up @@ -256,6 +264,10 @@ func TestDetermineInstallCmd_Snapshot(t *testing.T) {
})
}

if mode == WithStreamlitEntry {
ctx.StreamlitEntry = optional.Some("streamlit_app.py")
}

ic := determineInstallCmd(&ctx)
snaps.MatchSnapshot(t, ic)
})
Expand Down Expand Up @@ -283,7 +295,11 @@ func TestDetermineStartCmd_Snapshot(t *testing.T) {
t.Run(string(pm)+"-"+mode, func(t *testing.T) {
t.Parallel()

fs := afero.NewMemMapFs()

ctx := pythonPlanContext{
Src: fs,
Config: plan.NewProjectConfigurationFromFs(fs, ""),
PackageManager: optional.Some(pm),
Entry: optional.Some("app.py"),
}
Expand Down Expand Up @@ -726,3 +742,81 @@ func TestHasDependencyWithFile_Unknown(t *testing.T) {
assert.False(t, HasDependencyWithFile(ctx, "flask"))
assert.False(t, HasDependencyWithFile(ctx, "bar"))
}

func TestDetermineStreamlitEntry_ByFile(t *testing.T) {
fs := afero.NewMemMapFs()
_ = afero.WriteFile(fs, "streamlit_app.py", []byte(`import streamlit as st
x = st.slider("Select a value")
st.write(x, "squared is", x * x)`), 0o644)
_ = afero.WriteFile(fs, "requirements.txt", []byte("streamlit"), 0o644)

config := plan.NewProjectConfigurationFromFs(fs, "")

ctx := &pythonPlanContext{
Src: fs,
Config: config,
PackageManager: optional.Some(types.PythonPackageManagerUnknown),
}

assert.Equal(t, "streamlit_app.py", determineStreamlitEntry(ctx))
}

func TestDetermineStreamlitEntry_ByConfig(t *testing.T) {
fs := afero.NewMemMapFs()
_ = afero.WriteFile(fs, "zeabur_streamlit_demo.py", []byte(`import streamlit as st
x = st.slider("Select a value")
st.write(x, "squared is", x * x)`), 0o644)
_ = afero.WriteFile(fs, "requirements.txt", []byte("streamlit"), 0o644)
_ = afero.WriteFile(fs, "zbpack.json", []byte(`{"streamlit": {"entry": "zeabur_streamlit_demo.py"}}`), 0o644)

config := plan.NewProjectConfigurationFromFs(fs, "")

ctx := &pythonPlanContext{
Src: fs,
Config: config,
PackageManager: optional.Some(types.PythonPackageManagerUnknown),
}

assert.Equal(t, "zeabur_streamlit_demo.py", determineStreamlitEntry(ctx))
}

func TestDetermineStreamlitEntry_ConfigPrecedeConvention(t *testing.T) {
fs := afero.NewMemMapFs()
_ = afero.WriteFile(fs, "zeabur_streamlit_demo.py", []byte(`import streamlit as st
x = st.slider("Select a value")
st.write(x, "squared is", x * x)`), 0o644)
_ = afero.WriteFile(fs, "app.py", []byte(`print('not me')`), 0o644)
_ = afero.WriteFile(fs, "requirements.txt", []byte("streamlit"), 0o644)
_ = afero.WriteFile(fs, "zbpack.json", []byte(`{"streamlit": {"entry": "zeabur_streamlit_demo.py"}}`), 0o644)

config := plan.NewProjectConfigurationFromFs(fs, "")

ctx := &pythonPlanContext{
Src: fs,
Config: config,
PackageManager: optional.Some(types.PythonPackageManagerUnknown),
}

assert.Equal(t, "zeabur_streamlit_demo.py", determineStreamlitEntry(ctx))
}

func TestDetermineStreamlitEntry_Cache(t *testing.T) {
fs := afero.NewMemMapFs()
_ = afero.WriteFile(fs, "zeabur_streamlit_demo.py", []byte(`import streamlit as st
x = st.slider("Select a value")
st.write(x, "squared is", x * x)`), 0o644)
_ = afero.WriteFile(fs, "app.py", []byte(`print('not me')`), 0o644)
_ = afero.WriteFile(fs, "requirements.txt", []byte("streamlit"), 0o644)
_ = afero.WriteFile(fs, "zbpack.json", []byte(`{"streamlit": {"entry": "zeabur_streamlit_demo.py"}}`), 0o644)

config := plan.NewProjectConfigurationFromFs(fs, "")

ctx := &pythonPlanContext{
Src: fs,
Config: config,
PackageManager: optional.Some(types.PythonPackageManagerUnknown),
}

assert.Equal(t, "zeabur_streamlit_demo.py", determineStreamlitEntry(ctx))
assert.Equal(t, "zeabur_streamlit_demo.py", ctx.StreamlitEntry.Unwrap())
}
3 changes: 3 additions & 0 deletions tests/python-streamlit/streamlit_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import streamlit as st
x = st.slider("Select a value")
st.write(x, "squared is", x * x)
Loading