Skip to content

Commit 1ef899d

Browse files
committed
07: Resource listing.
1 parent fcb80ce commit 1ef899d

File tree

15 files changed

+511
-68
lines changed

15 files changed

+511
-68
lines changed

docs/building/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ playwright
3232
first_pyscript
3333
bulma
3434
examples_template
35+
resource_listing
3536
```

docs/building/resource_listing.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Resource Listing
2+
3+
Our navigation needs to list the available examples.
4+
Equally, we need a cleaner way to extract the data from an example.
5+
In this step, we make "resource" objects for various models: page, example, contributor.
6+
We'll leave page and contributor for the next step.
7+
8+
Big ideas: A standard model helps us with all the representations of an example.
9+
10+
## What's an Example?
11+
12+
We'll start with, of course, tests.
13+
This time in `test_resources`.
14+
Let's write `test_example` and see if we can correctly construct an instance.
15+
It will need all the bits the template relies on.
16+
17+
Our resource implementation will use dataclasses, with a base `Resource`.
18+
We'll use `__post_init__` to fill in the bits by opening the file.
19+
Also, as "keys", we'll use a `PurePath` scheme to name/id each example.
20+
21+
To help testing and to keep `__post_init__` dead-simple, we move most of the logic to easily-testable helpers.
22+
23+
## Gathering the Resources
24+
25+
We'll make a "resource dB" available at startup with the examples.
26+
For now, we'll do a `Resources` dataclass with an examples field as `dict[PurePath, Example]`.
27+
Later, we'll add fields for pages and contributors.
28+
29+
First a test, of course, to see if `get_resources` returns a `Resources.examples` with `PurePath("hello_world")` mapping to an `Example`.
30+
31+
We then write the `resources.get_resources` function that generates a populated `Resources`.
32+
33+
## Listing Examples
34+
35+
Now that we have the listing of examples, we head back to `app.py` and put it to work.
36+
We'll use Starlette's "lifespan" support to:
37+
38+
- Register a startup function which...
39+
- Runs `get_resources` and...
40+
- Assigns to `app.state.resources`
41+
42+
We'll then change the `example` view to get the resource from `request.app.state.resources`.
43+
44+
When we do this, though, `TestClient` breaks.
45+
It doesn't ordinarily run the lifecycle methods.
46+
Instead, we need to use the [context manager](https://www.starlette.io/events/#running-event-handlers-in-tests).
47+
We do this, and along the way, refactor the tests to inject the test_client.
48+
In fact, we also make a `get_soup` fixture that further eases test writing.
49+
50+
We then add a `/examples` view, starting with a test of course.
51+
This uses an `examples.jinja2` template.
52+
We wire this up into the navbar and have the test ensure that it is there via navbar.
53+
54+
The listings use a [Bulma tile 3 column](https://bulma.io/documentation/layout/tiles/#3-columns) layout.
55+
Lots that can be done here.
56+
57+
## Cleanup
58+
59+
- Arrange for `/example/hello_world` and `/example/hello_world/index.html` to both resolve
60+
- Fix the silly BeautifulSoup "allow str or List[str]" in upstream so tests don't have to cast all the time
61+
- Get a "subtitle" from `/examples/hello_world/index.html` and a `<meta name="subtitle" content="...">` tag
62+
- Don't extract `<main>` itself from the example, as we want to control it in the layout...just the children
63+
64+
## Future
65+
66+
- Have the rows in the tiles be yielded by a generator, allowing multi-column title
67+
- Include contributor information at bottom of each tile

src/psc/app.py

Lines changed: 47 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Provide a web server to browse the examples."""
2-
from http.client import HTTPException
3-
from pathlib import Path
2+
import contextlib
3+
from pathlib import PurePath
4+
from typing import AsyncContextManager
5+
from typing import Iterator
46

5-
from bs4 import BeautifulSoup
67
from starlette.applications import Starlette
78
from starlette.requests import Request
89
from starlette.responses import FileResponse
@@ -12,12 +13,14 @@
1213
from starlette.templating import Jinja2Templates
1314
from starlette.templating import _TemplateResponse
1415

16+
from psc.here import HERE
1517
from psc.here import PYODIDE
1618
from psc.here import PYSCRIPT
17-
from psc.here import STATIC
19+
from psc.resources import Example
20+
from psc.resources import Resources
21+
from psc.resources import get_resources
1822

1923

20-
HERE = Path(__file__).parent
2124
templates = Jinja2Templates(directory=HERE / "templates")
2225

2326

@@ -40,53 +43,34 @@ async def homepage(request: Request) -> _TemplateResponse:
4043
)
4144

4245

46+
async def examples(request: Request) -> _TemplateResponse:
47+
"""Handle the examples listing page."""
48+
these_examples: Iterator[Example] = request.app.state.resources.examples.values()
49+
50+
return templates.TemplateResponse(
51+
"examples.jinja2",
52+
dict(
53+
title="Examples",
54+
examples=these_examples,
55+
request=request,
56+
),
57+
)
58+
59+
4360
async def example(request: Request) -> _TemplateResponse:
4461
"""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)
62+
example_path = PurePath(request.path_params["example_name"])
63+
resources: Resources = request.app.state.resources
64+
this_example = resources.examples[example_path]
8265

8366
return templates.TemplateResponse(
8467
"example.jinja2",
8568
dict(
86-
title=title,
87-
extra_head=extra_head,
88-
main=main,
89-
extra_pyscript=extra_pyscript,
69+
title=this_example.title,
70+
subtitle=this_example.subtitle,
71+
extra_head=this_example.extra_head,
72+
main=this_example.main,
73+
extra_pyscript=this_example.extra_pyscript,
9074
request=request,
9175
),
9276
)
@@ -96,11 +80,26 @@ async def example(request: Request) -> _TemplateResponse:
9680
Route("/", homepage),
9781
Route("/index.html", homepage),
9882
Route("/favicon.png", favicon),
83+
Route("/examples/index.html", examples),
84+
Route("/examples", examples),
9985
Route("/examples/{example_name}/index.html", example),
86+
Route("/examples/{example_name}/", example),
10087
Mount("/examples", StaticFiles(directory=HERE / "examples")),
101-
Mount("/static", StaticFiles(directory=STATIC)),
88+
Mount("/static", StaticFiles(directory=HERE / "static")),
10289
Mount("/pyscript", StaticFiles(directory=PYSCRIPT)),
10390
Mount("/pyodide", StaticFiles(directory=PYODIDE)),
10491
]
10592

106-
app = Starlette(debug=True, routes=routes)
93+
94+
@contextlib.asynccontextmanager # type: ignore
95+
async def lifespan(a: Starlette) -> AsyncContextManager: # type: ignore
96+
"""Run the resources factory at startup and make available to views."""
97+
a.state.resources = get_resources()
98+
yield
99+
100+
101+
app = Starlette(
102+
debug=True,
103+
routes=routes,
104+
lifespan=lifespan,
105+
)

src/psc/examples/hello_world/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
<meta name="viewport" content="width=device-width,initial-scale=1">
1111

1212
<title>Hello World</title>
13+
<meta name="subtitle" content="The classic hello world, but in Python -- in a browser!" >
1314

1415
<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>
16+
<script defer src="../../pyscript/pyscript.js"></script>
1717
<link rel="stylesheet" href="hello_world.css">
1818
<script defer src="hello_world.js"></script>
1919
</head>

src/psc/examples/hello_world/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This is the *hello world* example.

src/psc/fixtures.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import field
66
from mimetypes import guess_type
77
from typing import Callable
8+
from typing import Generator
89
from urllib.parse import urlparse
910

1011
import pytest
@@ -45,18 +46,17 @@ def get(self, url: str) -> Response:
4546

4647

4748
@pytest.fixture
48-
def test_client() -> TestClient:
49-
"""Get a TestClient for the app."""
50-
return TestClient(app)
49+
def test_client() -> Generator[TestClient, None, None]:
50+
"""Return the app in a context manager to allow lifecyle to run."""
51+
with TestClient(app) as client:
52+
yield client
5153

5254

5355
def _base_page(client: TestClient | MockTestClient) -> PageT:
5456
"""Automate ``TestClient`` to return BeautifulSoup.
5557
56-
Along the way, default to raising an exception if the status
57-
code isn't 200.
58+
Default to raising an exception if the status code isn't 200.
5859
"""
59-
# Allow passing in a fake TestClient, for testing this fixture.
6060

6161
def _page(url: str, *, enforce_status: bool = True) -> BeautifulSoup:
6262
"""Callable that retrieves and returns soup."""

0 commit comments

Comments
 (0)