Skip to content

Commit 6e824bc

Browse files
committed
04: First PyScript.
1 parent b50da93 commit 6e824bc

File tree

15 files changed

+32859
-18
lines changed

15 files changed

+32859
-18
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,5 @@ node_modules/
141141
/.nox/
142142
/dist/
143143
/docs/_build/
144+
/src/psc/pyodide/
145+
/src/psc/pyscript/

docs/building/first_pyscript.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# First PyScript Example
2+
3+
Well, that was certainly a lot of prep.
4+
5+
Let's get into PyScript and examples.
6+
In this step we'll add the "Hello World" example along with unit/shallow/full tests.
7+
We will _not_ though go further into how this example gets listed.
8+
We also won't do any automation across examples: each example gets its own tests.
9+
10+
Big ideas: tests run offline and faster, no quirks for threaded server, much simpler "wait" for DOM.
11+
12+
## Re-Organize Tests
13+
14+
In the previous step, we made an `src/psc/examples` directory with `first.html` in it.
15+
Let's remove `first.html` and instead, have a `hello_world` directory with `index.html` in it.
16+
For now, it will be the same content as `first.html`, though we need to change the CSS path to `../static/psc.css`.
17+
18+
We also have our "first example" tests in `test_app.py`.
19+
Let's leave that test file to test the application itself, not each individual test.
20+
Thus, let's start `tests/examples/test_hello_world.py` and move `test_first_example*` into it.
21+
We'll finish with `test_hello_world` and `test_hello_world_full` in that file.
22+
23+
With these changes, the tests pass.
24+
Let's change the example to be the actual PyScript `Hello World` HTML file.
25+
26+
## Download PyScript/Pyodide Into Own Directories
27+
28+
Using `curl`, I grabbed the latest `pyscript.js` plus the `.map` etc.
29+
I did NOT check these into the repo.
30+
Instead, I made a `src/pyscript` directory, which also meant making a route, which also meant making a test.
31+
32+
This brings up an interesting question about versions.
33+
Should the Collective examples all use the same PyScript/Pyodide versions, or do we need to support variations?
34+
35+
Next up, Pyodide.
36+
I got the `.bz2` from the releases and uncompressed/untarred into a release directory.
37+
Bit by bit, I copied over pieces until "Hello World" loaded:
38+
39+
- The `.mjs` and `.asm*`
40+
- `packages.json`
41+
- The distutils.tar and pyodide_py.tar files
42+
- `.whl` directories for micropip, packaging, and pyparsing
43+
44+
Later we'll provide a command-line step to automate this (and to handle it for CI.)
45+
46+
## Hello World Example
47+
48+
Back to `src/psc/examples/hello_world/index.html`.
49+
Before starting, we should ensure the shallow test -- `TestClient` -- in `test_hello_world.py` works.
50+
51+
To set up PyScript, first, in `head`:
52+
53+
```html
54+
<link rel="icon" type="image/png" href="../../favicon.png" />
55+
<script defer src="../../static/pyscript.js"></script>
56+
```
57+
58+
That gets PyScript stuff.
59+
60+
To get Pyodide from a local installation instead of the network, I added `<py-config>`:
61+
62+
```xml
63+
<py-config>
64+
- autoclose_loader: true
65+
runtimes:
66+
- src: "../../pyodide/pyodide.js"
67+
name: pyodide-0.20
68+
lang: python
69+
</py-config>
70+
```
71+
72+
This was complicated by a few factors:
73+
74+
- The [PyScript docs page is broken](https://github.com/pyscript/pyscript/issues/528)
75+
- There are no examples in PyScript (and thus no tests) that show a working version of `<py-config>`
76+
- The default value on `autoclose_loader` appears to be `false` so if you use `<py-config>` you need to explicitly turn it off.
77+
- If you reformat your code, the YAML gets mis-formatted and it is a pain to debug
78+
- We want the path to `pyodide.js` to work if you load the `index.html` directly or through a server
79+
80+
At this point, the page loaded correctly in a browser, going to `http://127.0.0.1:3000/examples/hello_world/index.html`.
81+
Now, on to Playwright.
82+
83+
## Playwright Interceptor
84+
85+
We're going to be handling more types of files now, so we change the `Content-Type` sniffing.
86+
Instead of looking at the extension, we use Python's `mimetypes` library.
87+
88+
For the test, we want to check that our PyScript output is written into the DOM.
89+
This doesn't happen immediately.
90+
In the PyScript repo, they sniff at console messages and do a backoff to wait for Pyodide execution.
91+
92+
Playwright has help for this.
93+
The `page` can [wait for a selector to be satisfied](https://playwright.dev/python/docs/api/class-page#page-wait-for-selector).
94+
95+
This is _so much nicer_.
96+
Tests run a LOT faster:
97+
98+
- Our assets (HTML, CSS, `pyscript.js`, `pyscript.css`) are served without an HTTP server
99+
- Pyodide itself isn't loaded from CDN nor even HTTP
100+
Also, if something goes wrong, you aren't stuck with a hung thread in `SimpleHTTPServer`.
101+
Finally, as I noticed when working on vacation with terrible Internet -- everything can run offline...the examples and their tests.
102+
103+
It was _very_ hard to get to this point, as I ran into a number of obscure bugs:
104+
105+
- The `<py-config>` YAML bug above was a multi-hour waste
106+
- Reading files as strings failed obscurely on Pyodide's `.asm.*` files
107+
- Ditto for MIME type, which needs to be `application/wasm` (though the interwebs are confusing on this)
108+
- Any time the flake8/black/prettier stack ran on stuff in static, all hell broke loose
109+
110+
## Debugging
111+
112+
It was kind of miserable getting to this point.
113+
What debugging techniques did I discover?
114+
115+
Foremost, running the Playwright test in "head-ful" mode and looking at both the Chromium console and the network tab.
116+
Playwright made it easy, including with the little controller UI app that launches and lets you step through:
117+
118+
```bash
119+
$ PWDEBUG=1 poetry run pytest -s tests/examples/test_hello_world.py
120+
```
121+
122+
For this, it can help to add a `page.pause()` after the `page.goto()`.
123+
Otherwise, use the Playwright controls in the popup window to step through the execution.
124+
125+
Next, when running like this, you can use Python `print()` statements that write to the console which launched the head-ful client.
126+
That's useful in the interceptor.
127+
You could alternatively do some console logging with Playwright's (cumbersome) syntax for talking to the page from Python.
128+
But diving into the Chromium console is a chore.
129+
130+
When things weren't in "fail catastrophically" mode, the most productive debugging was...in the debugger.
131+
I set a breakpoint interceptor code, ran the tests, and stopped on each "file" request.
132+
133+
Finally, the most important technique was to...slow down and work methodically with unit tests.
134+
I should have done this from the start, hardening the interceptor and its surface area with Playwright.
135+
I spent hours on things a decent test (and even `mypy`) would have told me about bytes vs. strings.
136+
137+
## QA Tools
138+
139+
When running `pre-commit`, it appears Prettier re-formats the YAML contents of `<py-config>`.
140+
I could have spent time to figure it out (e.g. skip those files, or teach Prettier how to handle it.)
141+
But it's not urgent, so I disabled Prettier in the `.pre-commit-config.yaml` file.
142+
143+
Flake8 had a lot of complaints with `pyscript.py`.
144+
I edited `.flake8` to turn off all the particular problems, to avoid editing the file itself.
145+
Probably needs a better solution.
146+
147+
`mypy` found a couple of actual bugs.
148+
Thanks, Python type hinting!
149+
(Although I did chicken out with a `type: ignore` on a bytes thing in a test.)
150+
151+
## Could Be Better
152+
153+
This is very much a prototype and lots could be better.
154+
155+
There are still bunches of failure modes in the interceptor, and when it fails, things get _very mysterious_.
156+
A good half-day of hardening and test-writing -- primarily unit tests -- would largely do it.
157+
To go further, using Starlette's `FileResponse` and making an "adapter" to Playwright's `APIResponse` would help.
158+
Starlette has likely learned a lot of lessons on file reading/responding.
159+
160+
Speaking of the response, this code does the minimum.
161+
Content length?
162+
Ha!
163+
Again, adopting more of a regular Python web framework like Starlette (or from the old days, `webob`) would be smart.
164+
165+
We could speed up test running with [ideas from Pyodide Playwright ticket](https://github.com/pyodide/pyodide/issues/2048).
166+
It looks fun to poke around on that, but the hours lost in hell discouraged me.
167+
It's pretty fast right now, and a great improvement over the status quo.
168+
But a 3x speedup seems interesting.
169+
170+
Finally, it's possible that async Playwright is the answer, for both general speedups and `wait_for_selector`.
171+
When I first dabbled at this though, it got horrible, quickly (integrating main loops, sprinkling async/await everywhere.)
172+
I then read something saying "don't do it unless you have to."

docs/building/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,6 @@ maxdepth: 1
2828
2929
proper_package
3030
cmd_runner
31+
playwright
32+
first_pyscript
3133
```

poetry.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ show_column_numbers = true
8686
show_error_codes = true
8787
show_error_context = true
8888
files = ["src/psc", "tests"]
89-
exclude = ".nox|docs|static"
89+
exclude = [".nox", "docs","static","pyodide"]
9090
no_implicit_optional = true
9191

9292
[[tool.mypy.overrides]]

src/psc/__init__.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
"""PyScript Collective."""
22

3-
from pathlib import Path
3+
__all__ = [
4+
"app",
5+
]
46

5-
6-
# Paths that can be referenced anywhere and get the right target.
7-
8-
SRC = Path(__file__).parent
9-
STATIC = SRC / "static"
7+
from .app import app

src/psc/app.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22

33
from starlette.applications import Starlette
44
from starlette.requests import Request
5+
from starlette.responses import FileResponse
56
from starlette.responses import HTMLResponse
67
from starlette.routing import Mount
78
from starlette.routing import Route
89
from starlette.staticfiles import StaticFiles
910

10-
from . import STATIC
11+
from .here import HERE, PYSCRIPT
12+
from .here import PYODIDE
13+
from .here import STATIC
14+
15+
16+
async def favicon(request: Request) -> FileResponse:
17+
"""Route just for serving the favicon."""
18+
return FileResponse(HERE / "favicon.png")
1119

1220

1321
def index_page(request: Request) -> HTMLResponse:
@@ -18,6 +26,10 @@ def index_page(request: Request) -> HTMLResponse:
1826
routes = [
1927
Route("/", index_page),
2028
Mount("/static", StaticFiles(directory=STATIC)),
29+
Route("/favicon.png", favicon),
30+
Mount("/examples", StaticFiles(directory=HERE / "examples")),
31+
Mount("/pyscript", StaticFiles(directory=PYSCRIPT)),
32+
Mount("/pyodide", StaticFiles(directory=PYODIDE)),
2133
]
2234

2335
app = Starlette(debug=True, routes=routes)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!--
2+
contributor: [email protected]
3+
tags: example-tag, another-example
4+
-->
5+
6+
<!DOCTYPE html>
7+
<html lang="en">
8+
<head>
9+
<meta charset="utf-8"/>
10+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
11+
12+
<title>PyScript Hello World</title>
13+
14+
<link rel="icon" type="image/png" href="../../favicon.png"/>
15+
<script defer src="../../pyscript/pyscript.js"></script>
16+
</head>
17+
18+
<body>
19+
<py-config>
20+
- autoclose_loader: true
21+
runtimes:
22+
- src: "../../pyodide/pyodide.js"
23+
name: pyodide-0.20
24+
lang: python
25+
</py-config>
26+
<div>Hello...</div>
27+
<py-script>
28+
print("<div>...world</div>")
29+
</py-script>
30+
</body>
31+
</html>

src/psc/favicon.png

1.71 KB
Loading

src/psc/fixtures.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from requests.models import Response
1515
from starlette.testclient import TestClient
1616

17-
from psc import SRC
17+
from psc.here import HERE
1818
from psc.app import app
1919

2020

@@ -144,7 +144,7 @@ def route_handler(page: Page, route: Route) -> None:
144144
headers = dict()
145145
if is_fake:
146146
# We should read something from the filesystem
147-
this_fs_path = SRC / this_path
147+
this_fs_path = HERE / this_path
148148
if this_fs_path.exists():
149149
status = 200
150150
mime_type = guess_type(this_fs_path)[0]
@@ -178,5 +178,5 @@ def _route_handler(route: Route) -> None:
178178
page.route("**", _route_handler)
179179

180180
# Don't spend 30 seconds on timeout
181-
page.set_default_timeout(4000)
181+
page.set_default_timeout(8000)
182182
return page

0 commit comments

Comments
 (0)