diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94dac68..4e20d70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,6 @@ jobs: matrix: include: - { python: "3.10", os: "ubuntu-latest", session: "pre-commit" } - - { python: "3.10", os: "ubuntu-latest", session: "safety" } - { python: "3.10", os: "ubuntu-latest", session: "mypy" } - { python: "3.10", os: "ubuntu-latest", session: "tests" } - { python: "3.10", os: "windows-latest", session: "tests" } diff --git a/TODO.md b/TODO.md index 4c2c819..1aec715 100644 --- a/TODO.md +++ b/TODO.md @@ -2,9 +2,20 @@ ## Now +- CDN mode + +## Soon + +- Start documentation for example writers + - Explain that we own the `src` in `py-config` +- Remove the hack in noxfile to have tests only run the "fast" ones + - Playright files get the example index.html directly + - And thus, don't have the py-config src re-pointed to cdn + - When run in nox, there are no local files and need to do CDN + ## Eventually -- Get nox to work with downloaded pyodide/pyscript +- Get numpy, pandas, etc. downloaded into local dir - Get rid of Poetry ## Done diff --git a/docs/developers/cdn_mode.md b/docs/developers/cdn_mode.md new file mode 100644 index 0000000..af3ccd4 --- /dev/null +++ b/docs/developers/cdn_mode.md @@ -0,0 +1,93 @@ +# 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/gallery/examples/py_config.cdn.toml b/src/psc/gallery/examples/py_config.cdn.toml new file mode 100644 index 0000000..961e7de --- /dev/null +++ b/src/psc/gallery/examples/py_config.cdn.toml @@ -0,0 +1,4 @@ +autoclose_loader = true + +[[runtimes]] +src = "https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js" diff --git a/src/psc/gallery/examples/py_config.toml b/src/psc/gallery/examples/py_config.local.toml similarity index 100% rename from src/psc/gallery/examples/py_config.toml rename to src/psc/gallery/examples/py_config.local.toml diff --git a/src/psc/resources.py b/src/psc/resources.py index faebbcf..bf96c22 100644 --- a/src/psc/resources.py +++ b/src/psc/resources.py @@ -4,6 +4,8 @@ """ from dataclasses import dataclass from dataclasses import field +from operator import attrgetter +from pathlib import Path from pathlib import PurePath from typing import cast @@ -13,6 +15,7 @@ from markdown_it import MarkdownIt from psc.here import HERE +from psc.here import PYODIDE EXCLUSIONS = ("pyscript.css", "pyscript.js", "favicon.png") @@ -46,10 +49,25 @@ def get_head_nodes(s: BeautifulSoup) -> str: return "" -def get_body_content(s: BeautifulSoup) -> str: +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 = 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") - 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 @@ -87,7 +105,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) @@ -138,14 +156,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/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..156a401 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() # type: ignore + 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..27ffc1a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -58,27 +58,15 @@ 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: """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_generic_example.py b/tests/test_generic_example.py index 4c23916..f9d1a84 100644 --- a/tests/test_generic_example.py +++ b/tests/test_generic_example.py @@ -38,15 +38,3 @@ def test_hello_world_js(test_client: TestClient) -> None: """Test the static assets for Hello World.""" response = test_client.get("/gallery/examples/hello_world/hello_world.js") assert response.status_code == 200 - - -# -# -# @pytest.mark.full -# def test_hello_world_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/index.html" -# fake_page.goto(url) -# title = fake_page.title() -# assert title == "Hello World Python" diff --git a/tests/test_resources.py b/tests/test_resources.py index ee1a6d9..8c12619 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,4 +1,5 @@ """Construct the various kinds of resources: example, page, contributor.""" +from pathlib import Path from pathlib import PurePath import pytest @@ -9,9 +10,14 @@ 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() + + @pytest.fixture def head_soup() -> BeautifulSoup: """Get an example with various nodes.""" @@ -54,11 +60,51 @@ def test_get_no_head_nodes() -> None: def test_get_main() -> None: - """Return the main node from an example.""" - example_html = '
Hello world
' + """Return the body nodes from an example.""" + example_html = 'abc' + soup = BeautifulSoup(example_html, "html5lib") + body = get_body_content(soup) + if IS_LOCAL: + assert body == 'abc' + else: + assert body == 'abc' + + +def test_get_py_config_local() -> None: + """Return the body node and test setting py-config src.""" + example_html = 'abc' soup = BeautifulSoup(example_html, "html5lib") body = get_body_content(soup) - assert body == '
Hello world
' + body_soup = BeautifulSoup(body, "html5lib") + 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 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.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: @@ -108,6 +154,13 @@ def test_missing_page() -> None: assert str(exc.value) == "No page at xxx" +def test_sorted_examples() -> None: + """Ensure a stable listing.""" + 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() @@ -126,3 +179,10 @@ def test_get_resources() -> None: about = resources.pages[about_path] assert about.title == "About the PyScript Collective" assert "

Helping" in about.body + + +def test_is_local_broken_path() -> None: + """Test the local case where a directory will not exist.""" + test_path = Path("/xxx") + actual = is_local(test_path) + assert not actual