Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MAINT Clean up hooks and run_tests_inside_pyodide #123

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion pytest_pyodide/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,56 @@
from collections.abc import Callable, Collection
from copy import deepcopy
from io import BytesIO
from pathlib import Path
from typing import Any, Protocol

import pytest
from _pytest.assertion.rewrite import AssertionRewritingHook, rewrite_asserts

from .copy_files_to_pyodide import copy_files_to_emscripten_fs
from .hook import ORIGINAL_MODULE_ASTS, REWRITTEN_MODULE_ASTS
from .runner import _BrowserBaseRunner
from .utils import package_is_built as _package_is_built

MaybeAsyncFuncDef = ast.FunctionDef | ast.AsyncFunctionDef

ORIGINAL_MODULE_ASTS: dict[str, ast.Module] = {}
REWRITTEN_MODULE_ASTS: dict[str, ast.Module] = {}


# Handling for pytest assertion rewrites
# First we find the pytest rewrite config. It's an attribute of the pytest
# assertion rewriting meta_path_finder, so we locate that to get the config.


def _get_pytest_rewrite_config() -> Any:
for meta_path_finder in sys.meta_path:
if isinstance(meta_path_finder, AssertionRewritingHook):
break
else:
return None
return meta_path_finder.config


# Now we need to parse the ast of the files, rewrite the ast, and store the
# original and rewritten ast into dictionaries. `run_in_pyodide` will look the
# ast up in the appropriate dictionary depending on whether or not it is using
# pytest assert rewrites.

REWRITE_CONFIG = _get_pytest_rewrite_config()
del _get_pytest_rewrite_config


def record_module_ast(module_path):
strfn = str(module_path)
if strfn in ORIGINAL_MODULE_ASTS:
return
source = Path(module_path).read_bytes()
tree = ast.parse(source, filename=strfn)
ORIGINAL_MODULE_ASTS[strfn] = tree
tree2 = deepcopy(tree)
rewrite_asserts(tree2, source, strfn, REWRITE_CONFIG)
REWRITTEN_MODULE_ASTS[strfn] = tree2


def package_is_built(package_name: str):
return _package_is_built(package_name, pytest.pyodide_dist_dir) # type: ignore[arg-type]
Expand Down
10 changes: 2 additions & 8 deletions pytest_pyodide/doctest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import ast
from collections.abc import Callable
from copy import copy
from doctest import DocTest, DocTestRunner, register_optionflag
Expand All @@ -17,17 +16,12 @@
from _pytest.scope import Scope
from pytest import Collector

from . import run_in_pyodide
from .hook import ORIGINAL_MODULE_ASTS
from .decorator import record_module_ast, run_in_pyodide

__all__ = ["patch_doctest_runner", "collect_doctests"]

record_module_ast(__file__)

# Record the ast of this file so we can use run_in_pyodide in here
# TODO: maybe extract this as a utility function for clarity?
ORIGINAL_MODULE_ASTS[__file__] = ast.parse(
Path(__file__).read_bytes(), filename=__file__
)
# make doctest aware of our `doctest: +RUN_IN_PYODIDE`` optionflag
RUN_IN_PYODIDE = register_optionflag("RUN_IN_PYODIDE")

Expand Down
3 changes: 0 additions & 3 deletions pytest_pyodide/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,6 @@ def wrapper(*args, **kwargs):
return use_variant


standalone = rename_fixture("selenium", "selenium_standalone")


@pytest.fixture(scope="function")
def selenium_standalone(request, runtime, web_server_main, playwright_browsers):
with selenium_common(
Expand Down
170 changes: 35 additions & 135 deletions pytest_pyodide/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,17 @@
will look in here for hooks to execute.
"""

import ast
import re
import sys
from argparse import BooleanOptionalAction
from copy import copy, deepcopy
from pathlib import Path
from typing import Any, cast
from typing import Any

import pytest
from _pytest.assertion.rewrite import AssertionRewritingHook, rewrite_asserts
from _pytest.python import (
pytest_pycollect_makemodule as orig_pytest_pycollect_makemodule,
)
from pytest import Collector, Session

from .copy_files_to_pyodide import copy_files_to_emscripten_fs
from .run_tests_inside_pyodide import (
close_pyodide_browsers,
get_browser_pyodide,
run_test_in_pyodide,
run_in_pyodide_generate_tests,
run_in_pyodide_runtest_call,
)
from .utils import parse_xfail_browsers

Expand Down Expand Up @@ -111,7 +102,6 @@ def pytest_configure(config):


def pytest_unconfigure(config):
close_pyodide_browsers()
try:
(
pytest.pyodide_run_host_test,
Expand Down Expand Up @@ -186,81 +176,27 @@ def pytest_collect_file(file_path: Path, parent: Collector):
return collect_doctests(file_path, parent, doctestmodules)


# Handling for pytest assertion rewrites
# First we find the pytest rewrite config. It's an attribute of the pytest
# assertion rewriting meta_path_finder, so we locate that to get the config.


def _get_pytest_rewrite_config() -> Any:
for meta_path_finder in sys.meta_path:
if isinstance(meta_path_finder, AssertionRewritingHook):
break
else:
return None
return meta_path_finder.config


# Now we need to parse the ast of the files, rewrite the ast, and store the
# original and rewritten ast into dictionaries. `run_in_pyodide` will look the
# ast up in the appropriate dictionary depending on whether or not it is using
# pytest assert rewrites.

REWRITE_CONFIG = _get_pytest_rewrite_config()
del _get_pytest_rewrite_config

ORIGINAL_MODULE_ASTS: dict[str, ast.Module] = {}
REWRITTEN_MODULE_ASTS: dict[str, ast.Module] = {}


def pytest_pycollect_makemodule(module_path: Path, parent: Collector) -> None:
source = module_path.read_bytes()
strfn = str(module_path)
tree = ast.parse(source, filename=strfn)
ORIGINAL_MODULE_ASTS[strfn] = tree
tree2 = deepcopy(tree)
rewrite_asserts(tree2, source, strfn, REWRITE_CONFIG)
REWRITTEN_MODULE_ASTS[strfn] = tree2
orig_pytest_pycollect_makemodule(module_path, parent)
from .decorator import record_module_ast

record_module_ast(module_path)
# orig_pytest_pycollect_makemodule(module_path, parent)


STANDALONE_FIXTURES = [
"selenium_standalone",
"selenium_standalone_noload",
"selenium_webworker_standalone",
]
def pytest_generate_tests(metafunc: Any) -> None:
if metafunc.config.option.run_in_pyodide:
run_in_pyodide_generate_tests(metafunc)


def _has_standalone_fixture(item):
for fixture in item._request.fixturenames:
if fixture in STANDALONE_FIXTURES:
for fixture in item.fixturenames:
if fixture.startswith("selenium") and "standalone" in fixture:
return True

return False


def modifyitems_run_in_pyodide(items: list[Any]):
# TODO: get rid of this
# if we are running tests in pyodide, then run all tests for each runtime
new_items = []
# if pyodide_runtimes is not a singleton this is buggy...
# pytest_collection_modifyitems is only allowed to filter and reorder items,
# not to add new ones...
for runtime in pytest.pyodide_runtimes: # type: ignore[attr-defined]
if runtime == "host":
continue
for x in items:
x = copy(x)
x.pyodide_runtime = runtime
new_items.append(x)
items[:] = new_items
return


def pytest_collection_modifyitems(items: list[Any]) -> None:
# TODO: is this the best way to figure out if run_in_pyodide was requested?
if items and items[0].config.option.run_in_pyodide:
modifyitems_run_in_pyodide(items)

# Run all Safari standalone tests first
# Since Safari doesn't support more than one simultaneous session, we run all
# selenium_standalone Safari tests first. We preserve the order of other
Expand All @@ -281,80 +217,44 @@ def _get_item_position(item):

@pytest.hookimpl(tryfirst=True)
def pytest_runtest_setup(item):
if item.config.option.run_in_pyodide:
if not hasattr(item, "fixturenames"):
return
if pytest.pyodide_runtimes and "runtime" in item.fixturenames:
pytest.skip(reason="pyodide specific test, can't run in pyodide")
else:
# Pass this test to pyodide runner
# First: make sure that pyodide has the test folder copied over
item_path = Path(item.path)
copy_files = list(item_path.parent.rglob("*"))
# If we have a pyodide build dist folder with wheels in, copy those over
# and install the wheels in pyodide so we can import this package for tests
dist_path = Path.cwd() / "dist"
if dist_path.exists():
copy_files.extend(list(dist_path.glob("*.whl")))

copy_files_with_destinations = []
for src in copy_files:
dest = Path("test_files") / src.relative_to(Path.cwd())
copy_files_with_destinations.append((src, dest))

class RequestType:
config = item.config
node = item

selenium = get_browser_pyodide(
request=cast(pytest.FixtureRequest, RequestType),
runtime=item.pyodide_runtime,
)
copy_files_to_emscripten_fs(
copy_files_with_destinations, selenium, install_wheels=True
)
else:
if not hasattr(item, "fixturenames"):
# Some items like DoctestItem have no fixture
return
if not pytest.pyodide_runtimes and "runtime" in item.fixturenames:
pytest.skip(reason="Non-host test")
elif not pytest.pyodide_run_host_test and "runtime" not in item.fixturenames:
pytest.skip("Host test")
if not item.config.option.run_in_pyodide:
maybe_skip_item(item)


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
if item.config.option.run_in_pyodide:
def maybe_skip_item(item):
if not hasattr(item, "fixturenames"):
# Some items like DoctestItem have no fixture
return

def _run_in_pyodide(self):
class RequestType:
config = item.config
node = item
if item.config.option.run_in_pyodide:
if "runtime" in item.fixturenames:
pytest.skip(reason="pyodide specific test, can't run in pyodide")
return

selenium = get_browser_pyodide(
request=cast(pytest.FixtureRequest, RequestType),
runtime=item.pyodide_runtime,
)
run_test_in_pyodide(self.nodeid, selenium)
if not pytest.pyodide_runtimes and "runtime" in item.fixturenames:
pytest.skip(reason="Non-host test")
elif not pytest.pyodide_run_host_test and "runtime" not in item.fixturenames:
pytest.skip("Host test")

item.runtest = _run_in_pyodide.__get__(item, item.__class__)
yield
return

def xfail_browsers_marker_impl(item):
browser = None
for fixture in item._fixtureinfo.argnames:
for fixture in item.fixturenames:
if fixture.startswith("selenium"):
browser = item.funcargs[fixture]
break

if not browser:
yield
return

xfail_msg = parse_xfail_browsers(item).get(browser.browser, None)
if xfail_msg is not None:
pytest.xfail(xfail_msg)

yield
return

@pytest.hookimpl(tryfirst=True)
def pytest_runtest_call(item):
if item.config.option.run_in_pyodide:
run_in_pyodide_runtest_call(item)
else:
xfail_browsers_marker_impl(item)
Loading