From fc2a5286f1439fc5454e58844b60490321bd318d Mon Sep 17 00:00:00 2001 From: pauleveritt Date: Sat, 19 Nov 2022 09:47:09 -0500 Subject: [PATCH 1/9] Do a writeup of the problem and solution. --- TODO.md | 7 +++ docs/developers/cdn_mode.md | 92 +++++++++++++++++++++++++++++++++++++ docs/developers/index.md | 12 +++++ docs/index.md | 1 + 4 files changed, 112 insertions(+) create mode 100644 docs/developers/cdn_mode.md create mode 100644 docs/developers/index.md diff --git a/TODO.md b/TODO.md index 4c2c819..b39a423 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,13 @@ ## Now +- CDN mode + +## Soon + +- Start documentation for example writers + - Explain that we own the `src` in `py-config` + ## Eventually - Get nox to work with downloaded pyodide/pyscript diff --git a/docs/developers/cdn_mode.md b/docs/developers/cdn_mode.md new file mode 100644 index 0000000..f3aae2b --- /dev/null +++ b/docs/developers/cdn_mode.md @@ -0,0 +1,92 @@ +# CDN Mode + +Add a way to get PyScript/Pyodide locally sometimes, but from CDN other times. + +## Why + +When running and testing locally, the developers (and example writers) want fast turnaround. +They don't want to keep going out on a possibly-slow network -- or even no-network, if offline. +In "production", though, we want people browsing the examples to get the CDN version. + +Other times are harder to decide. +GitHub Actions would like a nice speedup. +But it will take some investigation to learn how to cache artifacts. + +## When + +There are several contexts where this decision needs to be made. + +## Standalone Example + +The Gallery examples are designed to allow people to preview an example's `index.html` without the app. +They have a ` - + packages=[ "altair", "pandas", diff --git a/src/psc/gallery/examples/antigravity/index.html b/src/psc/gallery/examples/antigravity/index.html index eac0361..88cd5d7 100644 --- a/src/psc/gallery/examples/antigravity/index.html +++ b/src/psc/gallery/examples/antigravity/index.html @@ -6,7 +6,7 @@ - + paths = ["./antigravity.py"] Based on xkcd: antigravity https://xkcd.com/353/. diff --git a/src/psc/gallery/examples/hello_world/index.html b/src/psc/gallery/examples/hello_world/index.html index 2b8e0a7..0a1add8 100644 --- a/src/psc/gallery/examples/hello_world/index.html +++ b/src/psc/gallery/examples/hello_world/index.html @@ -7,7 +7,7 @@ - +

Hello ...

More Info
diff --git a/src/psc/gallery/examples/hello_world_py/hello_world.py b/src/psc/gallery/examples/hello_world_py/hello_world.py index d464d41..8744a18 100644 --- a/src/psc/gallery/examples/hello_world_py/hello_world.py +++ b/src/psc/gallery/examples/hello_world_py/hello_world.py @@ -1,3 +1,3 @@ """Say Hello.""" -output = Element("output") #type: ignore +output = Element("output") # type: ignore output.write("From Python...") diff --git a/src/psc/gallery/examples/interest_calculator/calculator.py b/src/psc/gallery/examples/interest_calculator/calculator.py index d9e1d70..9383ffc 100644 --- a/src/psc/gallery/examples/interest_calculator/calculator.py +++ b/src/psc/gallery/examples/interest_calculator/calculator.py @@ -1,3 +1,6 @@ +"""Calculate the interest.""" + + def interest(*args, **kwargs): """Main interest calculation function.""" # Signal that PyScript is alive by setting the ``Calculate`` diff --git a/src/psc/gallery/examples/interest_calculator/index.html b/src/psc/gallery/examples/interest_calculator/index.html index 311aa2e..c9e8635 100644 --- a/src/psc/gallery/examples/interest_calculator/index.html +++ b/src/psc/gallery/examples/interest_calculator/index.html @@ -89,4 +89,3 @@ - diff --git a/src/psc/resources.py b/src/psc/resources.py index c76ddb8..c2aefdf 100644 --- a/src/psc/resources.py +++ b/src/psc/resources.py @@ -4,7 +4,8 @@ """ from dataclasses import dataclass from dataclasses import field -from pathlib import PurePath, Path +from pathlib import Path +from pathlib import PurePath from typing import cast import frontmatter @@ -12,14 +13,16 @@ from bs4 import Tag from markdown_it import MarkdownIt -from psc.here import HERE, PYODIDE +from psc.here import HERE +from psc.here import PYODIDE + EXCLUSIONS = ("pyscript.css", "pyscript.js", "favicon.png") def tag_filter( - tag: Tag, - exclusions: tuple[str, ...] = EXCLUSIONS, + tag: Tag, + exclusions: tuple[str, ...] = EXCLUSIONS, ) -> bool: """Filter nodes from example that should not get included.""" attr = "href" if tag.name == "link" else "src" @@ -45,24 +48,25 @@ def get_head_nodes(s: BeautifulSoup) -> str: return "" -def is_local(test_path: Path | None = PYODIDE) -> bool: +def is_local(test_path: Path = PYODIDE) -> bool: """Use a policy to decide local vs. CDN mode.""" - return test_path.exists() -def get_body_content(s: BeautifulSoup, test_path: Path | None = PYODIDE) -> str: +def get_body_content(s: BeautifulSoup, test_path: Path = PYODIDE) -> str: """Get the body node but raise an exception if not present.""" - # Choose the correct TOML file for local vs remote. toml_name = "local" if is_local(test_path) else "cdn" src = f"../py_config.{toml_name}.toml" # Get the body and patch the py_config src body_element = s.select_one("body") - py_config = body_element.find("py-config") - py_config["src"] = src - return f"{body_element.decode_contents()}" + if body_element: + py_config = body_element.select_one("py-config") + if py_config: + py_config.attrs["src"] = src + return f"{body_element.decode_contents()}" + return "" @dataclass @@ -100,7 +104,7 @@ def __post_init__(self) -> None: # Main, extra head example's HTML file. index_html_file = HERE / "gallery/examples" / self.path / "index.html" - if not index_html_file.exists(): + if not index_html_file.exists(): # pragma: nocover raise ValueError(f"No example at {self.path}") soup = BeautifulSoup(index_html_file.read_text(), "html5lib") self.extra_head = get_head_nodes(soup) diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 0000000..9bd866f --- /dev/null +++ b/tests/examples/__init__.py @@ -0,0 +1 @@ +"""Make these examples a packge.""" diff --git a/tests/examples/test_hello_world_py.py b/tests/examples/test_hello_world_py.py index 822ca11..c4a7828 100644 --- a/tests/examples/test_hello_world_py.py +++ b/tests/examples/test_hello_world_py.py @@ -9,7 +9,7 @@ from psc.fixtures import PageT -def test_hello_world(client_page: PageT) -> None: +def test_hello_world_client(client_page: PageT) -> None: """Test the static HTML for Hello World.""" soup = client_page("/gallery/examples/hello_world_py/") title = soup.select_one("title") @@ -31,7 +31,7 @@ def write(self, value: str) -> None: def test_hello_world_python() -> None: """Unit test the hello_world.py that is loaded.""" try: - builtins.Element = FakeElement + builtins.Element = FakeElement # type: ignore from psc.gallery.examples.hello_world_py.hello_world import output finally: delattr(builtins, "Element") @@ -40,7 +40,7 @@ def test_hello_world_python() -> None: @pytest.mark.full -def test_hello_world_full(fake_page: Page) -> None: +def test_hello_world_py_full(fake_page: Page) -> None: """Use Playwright to do a test on Hello World.""" # Use `PWDEBUG=1` to run "head-ful" in Playwright test app url = "http://fake/gallery/examples/hello_world_py/index.html" diff --git a/tests/examples/test_interest_calculator.py b/tests/examples/test_interest_calculator.py index 9a01cd3..3257197 100644 --- a/tests/examples/test_interest_calculator.py +++ b/tests/examples/test_interest_calculator.py @@ -1,12 +1,14 @@ """Test the ``Antigravity`` example.""" import pytest +from examples.test_hello_world_py import FakeElement from playwright.sync_api import Page +from psc.fixtures import FakeDocument from psc.fixtures import PageT -def test_calculator(fake_document, fake_element) -> None: +def test_calculator(fake_document: FakeDocument, fake_element: FakeElement) -> None: """Ensure the loaded interest function works correctly.""" from psc.gallery.examples.interest_calculator.calculator import interest @@ -16,11 +18,23 @@ def test_calculator(fake_document, fake_element) -> None: fake_document.values["simple_interest"] = "0.1" fake_document.values["compound_interest"] = "0.1" fake_document.values["calc"] = "Calculate" - interest() + interest() # type: ignore assert fake_document.log[0] == "simple interest: 200" assert fake_document.log[1] == "compound interest: 259" +def test_calculator_setup( + fake_document: FakeDocument, fake_element: FakeElement +) -> None: + """Ensure the loaded interest function works correctly.""" + from psc.gallery.examples.interest_calculator.calculator import setup + + fake_document.values["calc"] = "Calculate" + setup() + node = fake_document.nodes["calc"] + assert "Removed disabled" == node.element.log[0] + + def test_interest_calculator(client_page: PageT) -> None: """Test the static HTML for Antigravity.""" soup = client_page("/gallery/examples/interest_calculator/") @@ -41,6 +55,7 @@ def test_interest_calculator_full(fake_page: Page) -> None: fake_page.get_by_text("Time").fill("10") si = fake_page.query_selector("#simple_interest") ci = fake_page.query_selector("#compound_interest") - button.click() - assert si.text_content() == "simple interest: 2000" - assert ci.text_content() == "compound interest: 2594" + if button: + button.click() + assert si and si.text_content() == "simple interest: 2000" + assert ci and ci.text_content() == "compound interest: 2594" diff --git a/tests/test_app.py b/tests/test_app.py index 085335f..e57ed32 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -70,15 +70,3 @@ def test_static(test_client: TestClient) -> None: """Ensure the app provides a /static/ route.""" response = test_client.get("/static/bulma.min.css") assert response.status_code == 200 - - -def test_pyscript(test_client: TestClient) -> None: - """Ensure the app provides a path to the ``pyscript`` static dir.""" - response = test_client.get("/pyscript/pyscript.js") - assert response.status_code == 200 - - -def test_pyodide(test_client: TestClient) -> None: - """Ensure the app provides a path to the ``pyodide`` static dir.""" - response = test_client.get("/pyodide/pyodide.js") - assert response.status_code == 200 diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 955854f..ab7ef87 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,4 +1,5 @@ """Ensure the test fixtures work as expected.""" +import builtins from typing import cast import pytest @@ -7,10 +8,11 @@ from playwright.sync_api import Route from starlette.testclient import TestClient -from psc.fixtures import DummyPage, ElementCallable +from psc.fixtures import DummyPage from psc.fixtures import DummyRequest from psc.fixtures import DummyResponse from psc.fixtures import DummyRoute +from psc.fixtures import ElementCallable from psc.fixtures import FakeDocument from psc.fixtures import MockTestClient from psc.fixtures import PageT @@ -21,7 +23,7 @@ def test_test_client(test_client: TestClient) -> None: """Ensure fixture returns an initialized TestClient.""" - assert test_client.app + assert test_client.base_url == "http://testserver" def test_mock_test_client() -> None: @@ -138,33 +140,38 @@ def test_route_handler_fake_bad_path() -> None: def test_fake_element_not_installed() -> None: """We don't request the fixture so it isn't available.""" - with pytest.raises(NameError): - Element # noqa + assert not hasattr(builtins, "Element") def test_fake_element_installed(fake_element: ElementCallable) -> None: """Element is available as ``fake_element`` installed it.""" - Element # noqa + assert hasattr(builtins, "Element") -def test_fake_element_find_element(fake_document: FakeDocument, fake_element: ElementCallable) -> None: +def test_fake_element_find_element( + fake_document: FakeDocument, fake_element: ElementCallable +) -> None: """The Element can get a value from the fake document.""" fake_document.values["btn1"] = "value1" - button = Element("btn1") # noqa + button = fake_element("btn1") assert button.value == "value1" -def test_fake_element_write(fake_document: FakeDocument, fake_element: ElementCallable) -> None: +def test_fake_element_write( + fake_document: FakeDocument, fake_element: ElementCallable +) -> None: """The Element can write a value that is captured.""" fake_document.values["btn1"] = "value1" - button = Element("btn1") # noqa + button = fake_element("btn1") button.write("Some Value") assert fake_document.log[0] == "Some Value" -def test_fake_element_remove_attribute(fake_document: FakeDocument, fake_element: ElementCallable) -> None: +def test_fake_element_remove_attribute( + fake_document: FakeDocument, fake_element: ElementCallable +) -> None: """The Element can pretend to remove an attribute.""" fake_document.values["btn1"] = "value1" - button = Element("btn1") # noqa - button.removeAttribute("disabled") + button = fake_element("btn1") + button.element.removeAttribute("disabled") assert fake_document.log == [] diff --git a/tests/test_resources.py b/tests/test_resources.py index daec80f..d4dca82 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,17 +1,22 @@ """Construct the various kinds of resources: example, page, contributor.""" -from pathlib import PurePath, Path +from pathlib import Path +from pathlib import PurePath import pytest from bs4 import BeautifulSoup -from psc.resources import Example, is_local +from psc.resources import Example from psc.resources import Page from psc.resources import get_body_content from psc.resources import get_head_nodes from psc.resources import get_resources +from psc.resources import is_local from psc.resources import tag_filter +IS_LOCAL = is_local() + + @pytest.fixture def head_soup() -> BeautifulSoup: """Get an example with various nodes.""" @@ -58,30 +63,47 @@ def test_get_main() -> None: example_html = 'abc' soup = BeautifulSoup(example_html, "html5lib") body = get_body_content(soup) - assert body == 'abc' + if IS_LOCAL: + assert body == 'abc' + else: + assert body == 'abc' def test_get_py_config_local() -> None: - """Return the main node and test setting py-config src.""" + """Return the body node and test setting py-config src.""" example_html = 'abc' soup = BeautifulSoup(example_html, "html5lib") body = get_body_content(soup) body_soup = BeautifulSoup(body, "html5lib") - py_config = body_soup.find("py-config") - actual = py_config["src"] - assert "../py_config.local.toml" == actual + py_config = body_soup.select_one("py-config") + if py_config: + actual = py_config.attrs["src"] + if IS_LOCAL: + assert "../py_config.local.toml" == actual + else: + assert "../py_config.cdn.toml" == actual def test_get_py_config_cdn() -> None: - """Return the main node and test setting py-config src.""" + """Return the body node and test setting py-config src.""" example_html = 'abc' soup = BeautifulSoup(example_html, "html5lib") test_path = Path("/x") body = get_body_content(soup, test_path=test_path) body_soup = BeautifulSoup(body, "html5lib") - py_config = body_soup.find("py-config") - actual = py_config["src"] - assert "../py_config.cdn.toml" == actual + py_config = body_soup.select_one("py-config") + if py_config: + actual = py_config.attrs["src"] + assert "../py_config.cdn.toml" == actual + + +def test_get_py_config_no_body() -> None: + """There is not a body node to get py-config from.""" + example_html = "
" + soup = BeautifulSoup(example_html, "html5lib") + test_path = Path("/x") + body = get_body_content(soup, test_path=test_path) + assert "" == body def test_example_bad_path() -> None: @@ -95,8 +117,8 @@ def test_example() -> None: this_example = Example(path=PurePath("hello_world")) assert this_example.title == "Hello World" assert ( - this_example.subtitle - == "The classic hello world, but in Python -- in a browser!" + this_example.subtitle + == "The classic hello world, but in Python -- in a browser!" ) assert "hello_world.css" in this_example.extra_head assert "

Hello ...

" in this_example.body @@ -140,8 +162,8 @@ def test_get_resources() -> None: hello_world = resources.examples[hello_world_path] assert hello_world.title == "Hello World" assert ( - hello_world.subtitle - == "The classic hello world, but in Python -- in a browser!" + hello_world.subtitle + == "The classic hello world, but in Python -- in a browser!" ) # Page @@ -151,12 +173,6 @@ def test_get_resources() -> None: assert "

Helping" in about.body -def test_is_local_no_path() -> None: - """Test the local case where a directory exists.""" - actual = is_local() - assert actual - - def test_is_local_broken_path() -> None: """Test the local case where a directory will not exist.""" test_path = Path("/xxx") From 7a09196ac272301c3b4a04125101ecac9ae47a6e Mon Sep 17 00:00:00 2001 From: pauleveritt Date: Sat, 19 Nov 2022 17:07:45 -0500 Subject: [PATCH 8/9] Set the sort order for the listing. --- src/psc/resources.py | 17 +++++++++++------ tests/test_app.py | 4 ++-- tests/test_resources.py | 17 +++++++++++------ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/psc/resources.py b/src/psc/resources.py index c2aefdf..52aa5ea 100644 --- a/src/psc/resources.py +++ b/src/psc/resources.py @@ -4,6 +4,7 @@ """ from dataclasses import dataclass from dataclasses import field +from operator import attrgetter from pathlib import Path from pathlib import PurePath from typing import cast @@ -16,13 +17,12 @@ from psc.here import HERE from psc.here import PYODIDE - EXCLUSIONS = ("pyscript.css", "pyscript.js", "favicon.png") def tag_filter( - tag: Tag, - exclusions: tuple[str, ...] = EXCLUSIONS, + tag: Tag, + exclusions: tuple[str, ...] = EXCLUSIONS, ) -> bool: """Filter nodes from example that should not get included.""" attr = "href" if tag.name == "link" else "src" @@ -155,14 +155,19 @@ class Resources: pages: dict[PurePath, Page] = field(default_factory=dict) +def get_sorted_examples() -> list[PurePath]: + """Return an alphabetized listing of the examples.""" + examples_dir = HERE / "gallery/examples" + examples = [e for e in examples_dir.iterdir() if e.is_dir()] + return sorted(examples, key=attrgetter("name")) + + def get_resources() -> Resources: """Factory to construct all the resources in the site.""" resources = Resources() # Load the examples - examples_dir = HERE / "gallery/examples" - examples = [e for e in examples_dir.iterdir() if e.is_dir()] - for example in examples: + for example in get_sorted_examples(): this_path = PurePath(example.name) this_example = Example(path=this_path) resources.examples[this_path] = this_example diff --git a/tests/test_app.py b/tests/test_app.py index e57ed32..27ffc1a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -58,12 +58,12 @@ def test_examples_listing(client_page: PageT) -> None: first_example = examples_soup.select_one("p.title a") assert first_example first_href = first_example.get("href") - assert first_href == "../gallery/examples/hello_world/" + assert first_href == "../gallery/examples/altair/" hello_soup = client_page(first_href) assert hello_soup title = hello_soup.select_one("title") assert title - assert title.text == "Hello World | PyScript Collective" + assert title.text == "Altair Visualization | PyScript Collective" def test_static(test_client: TestClient) -> None: diff --git a/tests/test_resources.py b/tests/test_resources.py index d4dca82..0bd5303 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -5,7 +5,7 @@ import pytest from bs4 import BeautifulSoup -from psc.resources import Example +from psc.resources import Example, get_sorted_examples from psc.resources import Page from psc.resources import get_body_content from psc.resources import get_head_nodes @@ -13,7 +13,6 @@ from psc.resources import is_local from psc.resources import tag_filter - IS_LOCAL = is_local() @@ -117,8 +116,8 @@ def test_example() -> None: this_example = Example(path=PurePath("hello_world")) assert this_example.title == "Hello World" assert ( - this_example.subtitle - == "The classic hello world, but in Python -- in a browser!" + this_example.subtitle + == "The classic hello world, but in Python -- in a browser!" ) assert "hello_world.css" in this_example.extra_head assert "

Hello ...

" in this_example.body @@ -153,6 +152,12 @@ def test_missing_page() -> None: assert str(exc.value) == "No page at xxx" +def test_sorted_examples() -> None: + examples = get_sorted_examples() + first_example = examples[0] + assert "altair" == first_example.name + + def test_get_resources() -> None: """Ensure the dict-of-dicts is generated with PurePath keys.""" resources = get_resources() @@ -162,8 +167,8 @@ def test_get_resources() -> None: hello_world = resources.examples[hello_world_path] assert hello_world.title == "Hello World" assert ( - hello_world.subtitle - == "The classic hello world, but in Python -- in a browser!" + hello_world.subtitle + == "The classic hello world, but in Python -- in a browser!" ) # Page From 412bc8cb144ac9b9276386c0fac1fce00a28571b Mon Sep 17 00:00:00 2001 From: pauleveritt Date: Sat, 19 Nov 2022 17:42:22 -0500 Subject: [PATCH 9/9] Fix some pre-commit. --- src/psc/resources.py | 5 +++-- tests/examples/test_interest_calculator.py | 2 +- tests/test_resources.py | 13 ++++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/psc/resources.py b/src/psc/resources.py index 52aa5ea..bf96c22 100644 --- a/src/psc/resources.py +++ b/src/psc/resources.py @@ -17,12 +17,13 @@ from psc.here import HERE from psc.here import PYODIDE + EXCLUSIONS = ("pyscript.css", "pyscript.js", "favicon.png") def tag_filter( - tag: Tag, - exclusions: tuple[str, ...] = EXCLUSIONS, + tag: Tag, + exclusions: tuple[str, ...] = EXCLUSIONS, ) -> bool: """Filter nodes from example that should not get included.""" attr = "href" if tag.name == "link" else "src" diff --git a/tests/examples/test_interest_calculator.py b/tests/examples/test_interest_calculator.py index 3257197..156a401 100644 --- a/tests/examples/test_interest_calculator.py +++ b/tests/examples/test_interest_calculator.py @@ -30,7 +30,7 @@ def test_calculator_setup( from psc.gallery.examples.interest_calculator.calculator import setup fake_document.values["calc"] = "Calculate" - setup() + setup() # type: ignore node = fake_document.nodes["calc"] assert "Removed disabled" == node.element.log[0] diff --git a/tests/test_resources.py b/tests/test_resources.py index 0bd5303..8c12619 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -5,14 +5,16 @@ import pytest from bs4 import BeautifulSoup -from psc.resources import Example, get_sorted_examples +from psc.resources import Example from psc.resources import Page from psc.resources import get_body_content from psc.resources import get_head_nodes from psc.resources import get_resources +from psc.resources import get_sorted_examples from psc.resources import is_local from psc.resources import tag_filter + IS_LOCAL = is_local() @@ -116,8 +118,8 @@ def test_example() -> None: this_example = Example(path=PurePath("hello_world")) assert this_example.title == "Hello World" assert ( - this_example.subtitle - == "The classic hello world, but in Python -- in a browser!" + this_example.subtitle + == "The classic hello world, but in Python -- in a browser!" ) assert "hello_world.css" in this_example.extra_head assert "

Hello ...

" in this_example.body @@ -153,6 +155,7 @@ def test_missing_page() -> None: def test_sorted_examples() -> None: + """Ensure a stable listing.""" examples = get_sorted_examples() first_example = examples[0] assert "altair" == first_example.name @@ -167,8 +170,8 @@ def test_get_resources() -> None: hello_world = resources.examples[hello_world_path] assert hello_world.title == "Hello World" assert ( - hello_world.subtitle - == "The classic hello world, but in Python -- in a browser!" + hello_world.subtitle + == "The classic hello world, but in Python -- in a browser!" ) # Page