import os
from projspec.proj import ProjectSpec, ParseFailed
from projspec.utils import _ipynb_to_py, run_subprocess, AttrDict
# TODO: webapp Servers should (optionally?) call threading.Timer(0.5, webbrowser.open(..));
# but then it must not block, and we need to set/infer the URL including port.
[docs]
class Django(ProjectSpec):
"""A python web app using the django framework"""
icon = "๐ง"
# this is the metadata settings reference
spec_doc = "https://docs.djangoproject.com/en/6.0/ref/settings/"
def match(self):
return "manage.py" in self.proj.basenames
def parse(self) -> None:
from projspec.artifact.process import Server
# global settings are in ./*/settings.py in a directory also containing urls.py
# the top-level; manage.py may have the line to locate it:
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
# and "mysite" is the suggestion in the tutorials;
# can also be given as --settings to manage.py
allpy = self.proj.fs.glob(f"{self.proj.url}/*/*.py")
# We could also choose to parse the settings or URLs - but they are required
s_dirs = {_.rsplit("/", 1)[0] for _ in allpy if _.endswith("settings.py")}
u_dirs = {_.rsplit("/", 1)[0] for _ in allpy if _.endswith("urls.py")}
maindir = s_dirs.intersection(u_dirs)
if not maindir:
raise ParseFailed
# each site is a subdirectory with admin.py and other stuff, typically
# each mapped to a different sub-URL.
appdirs = [_.rsplit("/", 2)[-2] for _ in allpy if _.endswith("admin.py")]
if appdirs:
self.contents["apps"] = appdirs
self.artifacts["server"] = Server(
proj=self.proj, cmd=["python", "manage.py", "runserver"]
)
@staticmethod
def _create(path, sitename="mysite", appname="myapp"):
os.makedirs(path, exist_ok=True)
cmd = ["python", "-m", "django", "startproject", sitename, path]
run_subprocess(cmd, cwd=path, output=False)
cmd = ["python", f"{path}/manage.py", "startapp", appname]
run_subprocess(cmd, cwd=path, output=False)
[docs]
class Streamlit(ProjectSpec):
"""Interactive graphical app served in the browser, with streamlit components"""
icon = "๐"
spec_doc = "https://docs.streamlit.io/deploy/streamlit-community-cloud/deploy-your-app/file-organization"
# see also "https://docs.streamlit.io/develop/api-reference/configuration/config.toml", which is
# mainly theme and server config.
server_args = {
"port_arg": "--server.address",
"address_arg": "--server.port",
}
def match(self) -> bool:
# more possible layouts
return bool(
{".streamlit", "streamlit_app.py"}.intersection(self.proj.basenames)
)
@staticmethod
def _create(path):
# `streamlit init` does this, without the toml file.
os.makedirs(f"{path}/.streamlit", exist_ok=True)
with open(f"{path}/.streamlit/config.toml", "wt") as f:
f.write(
"""
[global]
[logger]
level = "info"
[server]
headless = true
"""
)
with open(f"{path}/streamlit_app.py", "wt") as f:
f.write(
"""
import streamlit as st
st.title("Streamlit minimal app")
st.write("Hello world!")
"""
)
if not os.path.exists(f"{path}/requirements.txt"):
with open(f"{path}/requirements.txt", "wt") as f:
f.write("streamlit")
elif "streamlit" not in open(f"{path}/requirements.txt").read():
with open(f"{path}/requirements.txt", "at") as f:
f.write("\nstreamlit")
def parse(self) -> None:
from projspec.content.environment import PythonRequirements, CondaEnv
from projspec.artifact.process import Server
# https://docs.streamlit.io/deploy/streamlit-community-cloud/deploy-your-app/app-dependencies
# a requirements.txt file is the most common and suggested way, but uv, pipenv and poetry are
# also supported - but they will get picked up as separate project types.
# Furthermore, the spec can live alongside the code in subdirectories, the apps in this
# project do not share an environment.
if "environment" not in self.proj.contents:
# just because of ordering
for cls in (PythonRequirements, CondaEnv):
if cls.match(self):
p = cls(self.proj)
p.parse()
self._contents["environment"] = p.contents["environment"]
break
if "environment" in self.proj.contents:
self._contents["environment"] = self.proj.contents["environment"]
# TODO: packages.txt lists packages to apt-get
# the common case is a single .py file
pyfiles = [v for v in self.proj.basenames if v.endswith(".py")]
if len(pyfiles) == 1:
self.artifacts["server"] = Server(
proj=self.proj,
cmd=[
"streamlit",
"run",
pyfiles[0].replace(self.proj.url, "").lstrip("/"),
],
)
else:
# TODO: use walk (top-down) here to avoid known directories like .venv/
pyfiles = self.proj.fs.glob(f"{self.proj.url}/**/*.py")
pycontent = self.proj.fs.cat(pyfiles)
self.artifacts["server"] = {}
for path, content in pycontent.items():
if "import streamlit as st" and "\nst." in content.decode():
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=[
"streamlit",
"run",
path.replace(self.proj.url, "").lstrip("/"),
],
)
[docs]
class Marimo(ProjectSpec):
"""Reactive Python notebook and webapp served in the browser"""
icon = "๐"
spec_doc = "https://docs.marimo.io/"
server_args = {"port_arg": "--port", "address_arg": "--host"}
def match(self) -> bool:
return any(fn.endswith(".py") for fn in self.proj.scanned_files)
def parse(self) -> None:
from projspec.artifact.process import Server
self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import marimo" in content or "from marimo" in content
has_app = "marimo.App(" in content or "= App(" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=["marimo", "run", path],
**self.server_args,
)
if not self.artifacts["server"]:
raise ParseFailed
@staticmethod
def _create(path):
with open(f"{path}/marimo-app.py", "wt") as f:
f.write(
"""
import marimo
__generated_with = "0.19.11"
app = marimo.App()
@app.cell
def _():
import marimo as mo
return "Hello, marimo!"
if __name__ == "__main__":
app.run()
"""
)
class Flask(ProjectSpec):
"""Lightweight web application framework in Python"""
icon = "๐งช"
spec_doc = "https://flask.palletsprojects.com/en/stable/config/"
server_args = {"port_arg": "--port", "address_arg": "--host"}
def match(self) -> bool:
# the default and common name for the main file is app.py
return (
any(fn.endswith(".py") for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import flask" in content or "from flask" in content
has_app = "flask.Flask(" in content or "= Flask(" in content
if has_import and has_app:
name = path.replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=["flask", "--app", name, "run"],
**self.server_args,
)
# read this one file anyway, if it wasn't already
if "app.py" in self.proj.basenames and "app.py" not in self.proj.scanned_files:
content = self.proj.fs.cat("app.py").decode()
# stash
self.proj.scanned_files["app.py"] = content
has_import = "import flask" in content or "from flask" in content
has_app = "flask.Flask(" in content or "= Flask(" in content
if has_import and has_app:
self.artifacts["server"]["app"] = Server(
proj=self.proj, cmd=["flask", "run"] ** self.server_args
)
if not self.artifacts["server"]:
raise ParseFailed
@staticmethod
def _create(path):
with open(f"{path}/flask-app.py", "wt") as f:
# https://flask.palletsprojects.com/en/stable/quickstart/#a-minimal-application
f.write(
"""
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello_world():
return "<p>Hello, World!</p>"
"""
)
class FastAPI(ProjectSpec):
"""Fast web application framework in Python"""
icon = "โก"
spec_doc = "https://fastapi.tiangolo.com/advanced/settings/"
server_args = {"port_arg": "--port", "address_arg": "--host"}
def match(self) -> bool:
# the default and common name for the main file is app.py
return (
any(fn.endswith(".py") for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import fastapi" in content or "from fastapi" in content
has_app = "FastAPI(" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=["fastapi", "run", path],
**self.server_args,
)
if not self.artifacts["server"]:
raise ParseFailed
@staticmethod
def _create(path):
with open(f"{path}/flask-app.py", "wt") as f:
# https://fastapi.tiangolo.com/#create-it
f.write(
"""
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
"""
)
class Dash(ProjectSpec):
"""Interactive data dashboarding with plotly components."""
icon = "๐"
spec_doc = "https://dash.plotly.com/tutorial" # no actual configuration
server_args = {"in_env": True, "port_arg": "PORT", "address_arg": "HOST"}
def match(self) -> bool:
# the default and common name for the main file is app.py
return (
any(fn.endswith(".py") for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import dash" in content or "from dash" in content
has_app = "Dash(" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj, cmd=["python", path], **self.server_args
)
if not self.artifacts["server"]:
raise ParseFailed
def _create(path: str) -> None:
with open(f"{path}/app.py", "wt") as f:
# https://dash.plotly.com/minimal-app
f.write(
"""from dash import Dash, html, dcc, callback, Output, Input
import plotly.express as px
import pandas as pd
df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/gapminder_unfiltered.csv')
app = Dash()
# Requires Dash 2.17.0 or later
app.layout = [
html.H1(children='Title of Dash App', style={'textAlign':'center'}),
dcc.Dropdown(df.country.unique(), 'Canada', id='dropdown-selection'),
dcc.Graph(id='graph-content')
]
@callback(
Output('graph-content', 'figure'),
Input('dropdown-selection', 'value')
)
def update_graph(value):
dff = df[df.country==value]
return px.line(dff, x='year', y='pop')
if __name__ == '__main__':
app.run(debug=True)
"""
)
[docs]
class Panel(ProjectSpec):
"""Interactive data dashboarding using panel, with holoviz/bokeh components."""
icon = "๐"
spec_doc = "https://panel.holoviz.org/api/config.html"
def match(self) -> bool:
# the default and common name for the main file is app.py
return (
any(fn.endswith((".py", ".ipynb")) for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
self.artifacts["server"] = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith((".py", ".ipynb")):
continue
content = content.decode()
if path.endswith(".ipynb"):
content = _ipynb_to_py(content)
has_import = "import panel" in content or "from panel" in content
has_app = ".servable(" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
self.artifacts["server"][name] = Server(
proj=self.proj,
cmd=["panel", "serve", path],
)
if not self.artifacts["server"]:
raise ParseFailed
def _create(path: str) -> None:
with open(f"{path}/app.py", "wt") as f:
# https://panel.holoviz.org/tutorials/basic/serve.html#serve-the-app
f.write(
"""import panel as pn
pn.extension()
pn.panel("Hello World").servable()
"""
)
[docs]
class Gradio(ProjectSpec):
"""Gradio machine learning demo and web app.
Detected by scanning Python files for `import gradio` or `gr.Interface` / `gr.Blocks`.
"""
icon = "๐๏ธ"
spec_doc = "https://www.gradio.app/docs/gradio/interface"
server_args = {"port_arg": "--server-port", "address_arg": "--server-name"}
def match(self) -> bool:
return (
any(fn.endswith(".py") for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
servers = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "import gradio" in content or "from gradio" in content
has_app = (
"gr.Interface(" in content
or "gr.Blocks(" in content
or "gradio.Interface(" in content
)
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
servers[name] = Server(
proj=self.proj,
cmd=["python", path],
**self.server_args,
)
if not servers:
raise ParseFailed
self._contents = AttrDict()
self._artifacts = AttrDict(server=servers)
@staticmethod
def _create(path: str) -> None:
with open(f"{path}/app.py", "wt") as f:
# https://www.gradio.app/guides/quickstart
f.write(
"""import gradio as gr
def greet(name):
return f"Hello, {name}!"
demo = gr.Interface(fn=greet, inputs="text", outputs="text")
if __name__ == "__main__":
demo.launch()
"""
)
[docs]
class Shiny(ProjectSpec):
"""Shiny for Python web application.
Detected by scanning Python files for `from shiny import` combined with
`app = App(` or `@app.` decorator usage. Also detects `app.py` at root.
"""
icon = "โญ"
spec_doc = "https://shiny.posit.co/py/docs/overview.html"
server_args = {"port_arg": "--port", "address_arg": "--host"}
def match(self) -> bool:
return (
any(fn.endswith(".py") for fn in self.proj.scanned_files)
or "app.py" in self.proj.basenames
)
def parse(self) -> None:
from projspec.artifact.process import Server
servers = {}
for path, content in self.proj.scanned_files.items():
if not path.endswith(".py"):
continue
content = content.decode()
has_import = "from shiny" in content or "import shiny" in content
has_app = "App(" in content or "@app." in content or "app_ui" in content
if has_import and has_app:
name = path.rsplit("/", 1)[-1].replace(".py", "")
servers[name] = Server(
proj=self.proj,
cmd=["shiny", "run", path],
**self.server_args,
)
if not servers:
raise ParseFailed
self._contents = AttrDict()
self._artifacts = AttrDict(server=servers)
@staticmethod
def _create(path: str) -> None:
with open(f"{path}/app.py", "wt") as f:
# https://shiny.posit.co/py/docs/overview.html
f.write(
"""from shiny import App, render, ui
app_ui = ui.page_fluid(
ui.h2("Hello, Shiny!"),
ui.input_text("name", "Enter your name:", value="World"),
ui.output_text_verbatim("greeting"),
)
def server(input, output, session):
@render.text
def greeting():
return f"Hello, {input.name()}!"
app = App(app_ui, server)
"""
)