Skip to content

Commit

Permalink
Add support for writing applications in Markdown (#4602)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Apr 5, 2023
1 parent 8664950 commit 872c136
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 6 deletions.
Binary file added doc/_static/markdown_sample.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions doc/how_to/display/examples/hello_world.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# My App

```python
import panel as pn

pn.extension(template='fast')
```

This application provides a minimal example demonstrating how to write an app in a Markdown file.

```.py
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, pn.bind(hello_world, widget)).servable()
```

```python
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, pn.bind(hello_world, widget)).servable()
```
7 changes: 7 additions & 0 deletions doc/how_to/display/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ How to rapidly develop a Panel application in your favorite IDE or editor.
How to use the Preview functionality in JupyterLab to rapidly develop applications.
:::

:::{grid-item-card} {octicon}`markdown;2.5em;sd-mr-1 sd-animate-grow50` Write apps in Markdown
:link: markdown
:link-type: doc

How to write Panel applications inside Markdown files.
:::

::::

```{toctree}
Expand Down
85 changes: 85 additions & 0 deletions doc/how_to/display/markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Write apps in Markdown

This guide addresses how to write Panel apps inside Markdown files.

---

Panel applications can be written as Python scripts (`.py`), notebooks (`.ipynb`) and also Markdown files (`.md`). This is particularly useful when writing applications that serve both as documentation and as an application, e.g. when writing a demo.

To begin simply create a Markdown file with the `.md` file extension, e.g. `app.md`. Once created give your app a title:

```markdown
# My App
```

Before adding any actual content add a code block with any imports your application needs. The code block should have one of two type declarations, either `python` or `{pyodide}`. The latter is useful if you also want to use [the Sphinx Pyodide integration](../wasm/sphinx.md). In this case we will simply declare a `python` code block that imports Panel and calls the extension with a specific template:

````markdown
```python
import panel as pn

pn.extension(template='fast')
```
````

Once we have initialized the extension any subsequent Markdown will be rendered as part of the application, e.g. we can put some description in our application. If you also want to render some Python code without having Panel interpret it as code, use `.py` as the language declaration:

````markdown
This application provides a minimal example demonstrating how to write an app in a Markdown file.

```.py
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, pn.bind(hello_world, widget)).servable()
```
````

Now we can add some actual Panel contents, again inside a `python` code block:

````markdown
```python
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, hello_world).servable()
```
````

To put it all together, here is what our app looks like:

````markdown
# My App

```python
import panel as pn

pn.extension(template='fast')
```

This application provides a minimal example demonstrating how to write an app in a Markdown file.

```.py
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, pn.bind(hello_world, widget)).servable()
```

```python
widget = pn.widgets.TextInput(value='world')

def hello_world(text):
return f'Hello {text}!'

pn.Row(widget, hello_world).servable()
```
````

![The rendered Panel application written as a Markdown file.](../../_static/markdown_sample.png)
2 changes: 1 addition & 1 deletion panel/io/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from bokeh.application.application import Application, SessionContext
from bokeh.application.handlers.code import CodeHandler
from bokeh.command.util import build_single_handler_application
from bokeh.core.json_encoder import serialize_json
from bokeh.core.templates import MACROS, get_env
from bokeh.document import Document
Expand All @@ -26,6 +25,7 @@

from .. import __version__, config
from ..util import base_version, escape
from .markdown import build_single_handler_application
from .mime_render import find_imports
from .resources import (
BASE_TEMPLATE, CDN_DIST, DIST_DIR, INDEX_TEMPLATE, Resources,
Expand Down
2 changes: 1 addition & 1 deletion panel/io/jupyter_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import tornado

from bokeh.command.util import build_single_handler_application
from bokeh.document import Document
from bokeh.embed.bundle import extension_dirs
from bokeh.protocol import Protocol
Expand All @@ -25,6 +24,7 @@
from ipykernel.comm import Comm

from ..util import edit_readonly
from .markdown import build_single_handler_application
from .resources import Resources
from .server import server_html_page_for_session
from .state import set_curdoc, state
Expand Down
97 changes: 97 additions & 0 deletions panel/io/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import os

from typing import IO

import bokeh.command.util

from bokeh.application.handlers.code import CodeHandler
from bokeh.command.util import (
build_single_handler_application as _build_application,
)


def extract_code(
filehandle: IO, supported_syntax: tuple[str, ...] = ('{pyodide}', 'python')
) -> str:
"""
Extracts Panel application code from a Markdown file.
"""
inblock = False
title = None
markdown = []
out = []
while True:
line = filehandle.readline()
if not line:
# EOF
break

if line.strip() == "":
continue

lsline = line.lstrip()
if lsline.startswith("```"):
if inblock:
inblock = False
continue
num_leading_backticks = len(lsline) - len(lsline.lstrip("`"))
syntax = line.strip()[num_leading_backticks:]
if syntax in supported_syntax:
if markdown:
md = '\n'.join(markdown)
markdown.clear()
if any('pn.extension' in o for o in out):
out.append(f"pn.pane.Markdown({md!r}).servable()\n")
inblock = True
else:
markdown.append(line)
elif inblock:
out.append(line)
elif line.startswith('# '):
title = line[1:].lstrip()
else:
markdown.append(line)
if markdown:
md = '\n'.join(markdown)
if any('pn.extension' in o for o in out):
out.append(f"pn.pane.Markdown({md!r}).servable()\n")
if title and any('template=' in o for o in out if 'pn.extension' in o):
out.append(f'pn.state.template.title = {title.strip()!r}')
return '\n'.join(out)

class MarkdownHandler(CodeHandler):
''' Modify Bokeh documents by creating Dashboard from a Markdown file.
'''

def __init__(self, *args, **kwargs):
'''
Keywords:
filename (str) : a path to a Markdown (".md") file
'''
if 'filename' not in kwargs:
raise ValueError('Must pass a filename to Handler')
filename = os.path.abspath(kwargs['filename'])
with open(filename, encoding='utf-8') as f:
code = extract_code(f)
kwargs['source'] = code
super().__init__(*args, **kwargs)

def build_single_handler_application(path, argv=None):
if not os.path.isfile(path) or not path.endswith(".md"):
return _build_application(path, argv)

from .server import Application
handler = MarkdownHandler(filename=path)
if handler.failed:
raise RuntimeError("Error loading %s:\n\n%s\n%s " % (path, handler.error, handler.error_detail))

application = Application(handler)

return application

bokeh.command.util.build_single_handler_application = build_single_handler_application
6 changes: 3 additions & 3 deletions panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
CodeHandler, _monkeypatch_io, patch_curdoc,
)
from bokeh.application.handlers.function import FunctionHandler
from bokeh.command.util import build_single_handler_application
from bokeh.core.templates import AUTOLOAD_JS
from bokeh.core.validation import silence
from bokeh.core.validation.warnings import EMPTY_LAYOUT
Expand Down Expand Up @@ -70,6 +69,7 @@
from .logging import (
LOG_SESSION_CREATED, LOG_SESSION_DESTROYED, LOG_SESSION_LAUNCHING,
)
from .markdown import build_single_handler_application
from .profile import profile_ctx
from .reload import autoreload_watcher
from .resources import (
Expand Down Expand Up @@ -884,7 +884,7 @@ def get_server(
continue
if isinstance(app, pathlib.Path):
app = str(app) # enables serving apps from Paths
if (isinstance(app, str) and (app.endswith(".py") or app.endswith(".ipynb"))
if (isinstance(app, str) and (app.endswith(".py") or app.endswith(".ipynb") or app.endswith('.md'))
and os.path.isfile(app)):
apps[slug] = app = build_single_handler_application(app)
app._admin = admin
Expand All @@ -896,7 +896,7 @@ def get_server(
else:
if isinstance(panel, pathlib.Path):
panel = str(panel) # enables serving apps from Paths
if (isinstance(panel, str) and (panel.endswith(".py") or panel.endswith(".ipynb"))
if (isinstance(panel, str) and (panel.endswith(".py") or panel.endswith(".ipynb") or panel.endswith('.md'))
and os.path.isfile(panel)):
apps = {'/': build_single_handler_application(panel)}
else:
Expand Down
2 changes: 1 addition & 1 deletion panel/template/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
'vanilla' : VanillaTemplate
}

_config.param.template.names = templates
_config.param.template.objects = list(templates)
_config.param.template.names = templates

__all__ = [
"BaseTemplate",
Expand Down
27 changes: 27 additions & 0 deletions panel/tests/command/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _populateQueue(stream, queue):

while True:
line = stream.readline()
print(line)
if line:
queue.put(line)
else:
Expand Down Expand Up @@ -140,3 +141,29 @@ def test_custom_html_index(relative, html_file):
r = requests.get(f"http://localhost:{port}/")
assert r.status_code == 200
assert r.content.decode('utf-8') == index

md_app = """
# My app
```python
import panel as pn
pn.extension(template='fast')
```
A description
```python
pn.Row('# Example').servable()
```
"""

@not_windows
def test_serve_markdown():
md = tempfile.NamedTemporaryFile(mode='w', suffix='.md')
write_file(md_app, md.file)

with run_panel_serve(["--port", "0", md.name]) as p:
port = wait_for_port(p.stdout)
r = requests.get(f"http://localhost:{port}/")
assert r.status_code == 200
assert '<title>My app</title>' in r.content.decode('utf-8')
59 changes: 59 additions & 0 deletions panel/tests/io/test_markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from io import StringIO

from panel.io.markdown import extract_code

md1 = """
```python
import panel as pn
pn.Row(1, 2, 3).servable()
```
"""

md2 = """
```{pyodide}
import panel as pn
pn.Row(1, 2, 3).servable()
```
"""

md3 = """
```python
import panel as pn
pn.extension()
```
My description
```python
pn.Row(1, 2, 3).servable()
```
"""

md4 = """
# My app
```python
import panel as pn
pn.extension(template='fast')
pn.Row(1, 2, 3).servable()
```
"""

def test_extract_panel_block():
f = StringIO(md1)
assert extract_code(f) == "import panel as pn\n\npn.Row(1, 2, 3).servable()\n"

def test_extract_pyodide_block():
f = StringIO(md2)
assert extract_code(f) == "import panel as pn\n\npn.Row(1, 2, 3).servable()\n"

def test_extract_description_block():
f = StringIO(md3)
assert extract_code(f) == "import panel as pn\n\npn.extension()\n\npn.pane.Markdown('My description\\n').servable()\n\npn.Row(1, 2, 3).servable()\n"

def test_extract_title_block():
f = StringIO(md4)
assert extract_code(f) == "import panel as pn\n\npn.extension(template='fast')\n\npn.Row(1, 2, 3).servable()\n\npn.state.template.title = 'My app'"

0 comments on commit 872c136

Please sign in to comment.