Skip to content

Commit fcb80ce

Browse files
committed
06: Examples with routes and templates.
1 parent 363cf1a commit fcb80ce

File tree

9 files changed

+228
-13
lines changed

9 files changed

+228
-13
lines changed

docs/building/examples_template.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Examples Template
2+
3+
Having a template is pretty slick.
4+
We'll now do the same for each example, but things are gonna get kinda weird: we need just part of the example's HTML.
5+
6+
## Example Template and Route
7+
8+
We'll start with a test.
9+
We already have a `TestClient` test at `test_hello_world.test_hello_world`.
10+
We start by adapting it to the same BeautifulSoup approach we just saw.
11+
12+
Next, an implementation.
13+
We add a template at `templates/example.jinja2` then make a new `example` view and route in `app.py`.
14+
By copying the existing view, we get something that works and, with a small test change, passes the tests.
15+
But it's returning the contents of the home page.
16+
17+
Instead, we:
18+
- Get the route parameter
19+
- Read that file
20+
- Use `beautifulsoup4` to extra the contents of `<main>`
21+
- Shove that into the template as the context value of `main`
22+
23+
Along the way, we also extract this example's title from the `<title>` in the HTML file.
24+
We then shove it in as the template context value of `title`.
25+
26+
This leaves out:
27+
- Everything in the `<head>`, such as...loading PyScript
28+
- All the `<py-*>` nodes elsewhere in the example's `<body>`
29+
30+
Uhhh...that's kind of dumb.
31+
Why are we doing that?
32+
33+
## Standalone vs. Integrated vs. Unmanaged
34+
35+
The HTML for an example might appear in a bunch of places:
36+
37+
1. *Standalone*.
38+
People want to cut-and-paste an example and run it from a `file:///` URL.
39+
The Contributor might want to start this way. It needs the PyScript JS/CSS and possibly a `<py-config>`.
40+
2. *Integrated Website*.
41+
In the website, for the "best" examples, we want everything to fit together well: consistent styling, fast page transitions, using the same PyScript/Pyodide.
42+
The Gallery should have control of these things, not the examples.
43+
Let's call those the "integrated" examples, vs. others that need their own control.
44+
3. *Unmanaged Website*.
45+
These are examples on the website which need to set their own Pyodide, or not use the Gallery CSS.
46+
4. *Integrated App*.
47+
These are when the examples are running in the Gallery Python web app, under Starlette.
48+
Perhaps the Contributor is browsing the example, perhaps a Coder is running the example via `pipx`.
49+
Mostly the same as "Integrated Website".
50+
5. *CI Builds Website*.
51+
In this case, the example is compiled into a `public` directory and included into the website.
52+
The example isn't really being executed.
53+
Rather, it's being assembled into output.
54+
55+
At this point, we're still in "Integrated App".
56+
The Starlette process wants an "integrated" example, where the CSS/JS/Pyodide is under the `layout.jinja2` control.
57+
All the "integrated" examples will look and feel consistent.
58+
59+
## Extra PyScript Stuff in Head
60+
61+
With that said, an "integrated" examples might have other static assets to go in the `<head>`: extra CSS, for example.
62+
We'll add that to our example.
63+
64+
Remember, these examples are "standalone".
65+
They include the `<link>` and `<script>` pointing to PyScript.
66+
We don't want *those* -- they come from `layout.jinja2`.
67+
We *do* want anything else them put in there, with relative links as the targets.
68+
69+
Let's write a failing first for including `hello_world.css`.
70+
For implementation:
71+
- Add a slot in `layout.jinja2`
72+
- Change `example.jinja2` to fill that slot, based on passed in string
73+
- Pass in a string of all the HTML to include
74+
- Build that string from a `beautifulsoup` `select`
75+
76+
## Example Template Needs `<py-config>`
77+
78+
We want the HTML for the examples to get a Gallery-managed `<py-config>`.
79+
But we don't want this in other, non-example pages.
80+
We'll add an `extra_body` slot in `layout.jinja2`, then fill it from `example.jinja2`.
81+
82+
Starting, of course, with a test.
83+
84+
## Plucking Example Parts
85+
86+
That's good for stuff in the `<head>`.
87+
But we have a problem in the `<body>`.
88+
PyScript only allows `<py-script>` as a direct child of `<body>`, so we can't put it in `<main>`.
89+
We need a policy like this:
90+
91+
- Anything in the example's "UI" (the DOM) goes in `<main>` and gets copied over
92+
- Any `<py-*>` nodes directly under `<body>` get copied over
93+
- *Except* `<py-config>`
94+
- Everything else in `<body>` is left out
95+
96+
We'll write some tests:
97+
- Ensure only one `<py-config>` with a runtime pointed to our local Pyodide
98+
- The `<py-script>` is copied over, in the right spot
99+
- Some tracer `<h6>` that is *outside* of `<main>` is *not* copied over
100+
101+
## QA
102+
103+
`mypy` gave us some trouble at the end, because `beautifulsoup` has some unusual typing.
104+
We thus moved the `example` view's soup filtering into a standalone function which had a `cast`.
105+
106+
## Future
107+
108+
This is actually pretty neat.
109+
But the view is doing too much.
110+
Later, we'll introduce a "resource" concept, kind of like a model, and move the work there.

docs/building/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ cmd_runner
3131
playwright
3232
first_pyscript
3333
bulma
34+
examples_template
3435
```

src/psc/app.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Provide a web server to browse the examples."""
2+
from http.client import HTTPException
23
from pathlib import Path
34

5+
from bs4 import BeautifulSoup
46
from starlette.applications import Starlette
57
from starlette.requests import Request
68
from starlette.responses import FileResponse
@@ -38,10 +40,63 @@ async def homepage(request: Request) -> _TemplateResponse:
3840
)
3941

4042

43+
async def example(request: Request) -> _TemplateResponse:
44+
"""Handle an example page."""
45+
example_name = request.path_params["example_name"]
46+
example_file = HERE / "examples" / example_name / "index.html"
47+
example_content = example_file.read_text()
48+
soup = BeautifulSoup(example_content, "html5lib")
49+
50+
# Get the example title from the HTML file
51+
title_node = soup.select_one("title")
52+
title = title_node.text if title_node else ""
53+
54+
# Assemble any extra head
55+
extra_head_links = [
56+
link.prettify()
57+
for link in soup.select("head link")
58+
if not link.attrs["href"].endswith("pyscript.css")
59+
and not link.attrs["href"].endswith("favicon.png")
60+
]
61+
extra_head_scripts = [
62+
script.prettify()
63+
for script in soup.select("head script")
64+
if not script.attrs["src"].endswith("pyscript.js")
65+
]
66+
extra_head_nodes = extra_head_links + extra_head_scripts
67+
extra_head = "\n".join(extra_head_nodes)
68+
69+
# Assemble the main element
70+
main_element = soup.select_one("main")
71+
if main_element is None:
72+
raise HTTPException("Example file has no <main> element")
73+
main = f"<main>{main_element.decode_contents()}</main>"
74+
75+
# Get any non-py-config PyScript nodes
76+
pyscript_nodes = [
77+
pyscript.prettify()
78+
for pyscript in soup.select("body > *")
79+
if pyscript.name.startswith("py-") and pyscript.name != "py-config"
80+
]
81+
extra_pyscript = "\n".join(pyscript_nodes)
82+
83+
return templates.TemplateResponse(
84+
"example.jinja2",
85+
dict(
86+
title=title,
87+
extra_head=extra_head,
88+
main=main,
89+
extra_pyscript=extra_pyscript,
90+
request=request,
91+
),
92+
)
93+
94+
4195
routes = [
4296
Route("/", homepage),
4397
Route("/index.html", homepage),
4498
Route("/favicon.png", favicon),
99+
Route("/examples/{example_name}/index.html", example),
45100
Mount("/examples", StaticFiles(directory=HERE / "examples")),
46101
Mount("/static", StaticFiles(directory=STATIC)),
47102
Mount("/pyscript", StaticFiles(directory=PYSCRIPT)),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
h1 {
2+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* Stuff here */

src/psc/examples/hello_world/index.html

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,34 @@
55

66
<!DOCTYPE html>
77
<html lang="en">
8-
<head>
9-
<meta charset="utf-8"/>
10-
<meta name="viewport" content="width=device-width,initial-scale=1"/>
8+
<head>
9+
<meta charset="utf-8">
10+
<meta name="viewport" content="width=device-width,initial-scale=1">
1111

12-
<title>PyScript Hello World</title>
12+
<title>Hello World</title>
1313

14-
<link rel="icon" type="image/png" href="../../favicon.png"/>
15-
<script defer src="../../pyscript/pyscript.js"></script>
16-
</head>
14+
<link rel="icon" type="image/png" href="../../favicon.png">
15+
<link rel="stylesheet" href="../../static/pyscript.css">
16+
<script defer src="../../static/pyscript.js"></script>
17+
<link rel="stylesheet" href="hello_world.css">
18+
<script defer src="hello_world.js"></script>
19+
</head>
1720

1821
<body>
1922
<py-config>
20-
- autoclose_loader: true
21-
runtimes:
23+
autoclose_loader: true
24+
runtimes:
2225
- src: "../../pyodide/pyodide.js"
2326
name: pyodide-0.20
2427
lang: python
2528
</py-config>
26-
<div>Hello...</div>
29+
<main>
30+
<h1>Hello ...</h1>
31+
<div id="output"></div>
32+
</main>
33+
<h6>More Info</h6>
2734
<py-script>
28-
print("<div>...world</div>")
35+
pyscript.write("output", "...world")
2936
</py-script>
3037
</body>
3138
</html>

src/psc/templates/example.jinja2

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% extends "layout.jinja2" %}
2+
{% block extra_head %}
3+
<script defer src="/pyscript/pyscript.js"></script>
4+
{{ extra_head | safe }}{% endblock %}
5+
{% block extra_body %}
6+
<py-config>
7+
autoclose_loader: true
8+
runtimes:
9+
- src: "/static/pyodide.js"
10+
name: pyodide-0.20
11+
lang: python
12+
</py-config>
13+
{{ extra_pyscript | safe }}
14+
{% endblock %}
15+
{% block main %}
16+
{{ main | safe }}
17+
{% endblock %}

src/psc/templates/layout.jinja2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
<title>{{ title }} | PyScript Collective</title>
77
<link rel="icon" href="/favicon.png">
88
<link rel="stylesheet" href="/static/bulma.min.css">
9+
{% block extra_head %}
10+
{% endblock %}
911
</head>
1012
<body>
1113
<nav class="navbar is-black" role="navigation" aria-label="main navigation">
@@ -29,6 +31,8 @@
2931
</div>
3032
</div>
3133
</nav>
34+
{% block extra_body %}
35+
{% endblock %}
3236
{% block main %}
3337
{% endblock %}
3438
<footer class="footer">

tests/examples/test_hello_world.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,26 @@ def test_hello_world(client_page: PageT) -> None:
99
"""Test the static HTML for Hello World."""
1010
soup = client_page("/examples/hello_world/index.html")
1111
title = soup.select_one("title")
12-
if title:
13-
assert title.text == "PyScript Hello World"
12+
assert title
13+
assert title.text == "Hello World | PyScript Collective"
14+
15+
# See if extra_head got filled, then resolve those
16+
assert soup.find_all("link", href="hello_world.css")
17+
assert soup.find_all("script", src="hello_world.js")
18+
19+
# Ensure the ``<main>`` got filled
20+
assert soup.select_one("main")
21+
22+
# Only one <py-config>, pointing to local runtime
23+
py_configs = soup.select("py-config")
24+
assert len(py_configs) == 1
25+
26+
# The <py-script> is present
27+
py_scripts = soup.select("py-script")
28+
assert len(py_scripts) == 1
29+
30+
# The tracer <h6> is not present
31+
assert not soup.select("h6")
1432

1533

1634
@pytest.mark.full

0 commit comments

Comments
 (0)