66from collections .abc import Callable , Collection
77from copy import deepcopy
88from io import BytesIO
9+ from pathlib import Path
10+ from secrets import token_hex
11+ from textwrap import dedent , indent
912from typing import Any , Protocol
1013
1114from .copy_files_to_pyodide import copy_files_to_emscripten_fs
@@ -371,7 +374,7 @@ def __new__(cls, function: Callable[..., Any] | None = None, /, **kwargs):
371374 # @run_in_pyodide
372375 # def f():
373376 # pass
374- return run_in_pyodide (** kwargs )(function )
377+ return cls (** kwargs )(function )
375378 # Just do normal __new__ behavior
376379 return object .__new__ (cls )
377380
@@ -449,7 +452,8 @@ def _run(self, selenium: SeleniumType, args: tuple[Any, ...]):
449452 selenium .load_package (self ._pkgs )
450453
451454 r = selenium .run_async (code )
452- [status , result , repr ] = r
455+ [status , result , repr , * extra ] = r
456+ self ._process_extra (* extra )
453457
454458 result = _decode (selenium , result , status , repr )
455459 if status :
@@ -462,18 +466,25 @@ def _code_template(self, args: tuple[Any, ...]) -> str:
462466 if the function is async await the result. Last, if there was an
463467 exception, pickle it and send it back.
464468 """
469+ # Indent by 12 to match the indentation level of the body of __tmp()
470+ prelude = indent (self ._get_code_prelude (), " " * 12 )
471+ epilogue = indent (self ._get_code_epilogue (), " " * 12 )
472+
465473 return f"""
466474 async def __tmp():
467475 __tracebackhide__ = True
468476
469477 from pytest_pyodide.decorator import run_in_pyodide_main
470- return run_in_pyodide_main(
478+ \n { prelude }
479+ result = await run_in_pyodide_main(
471480 { _encode (self ._mod )!r} ,
472481 { _encode (args )!r} ,
473482 { self ._module_filename !r} ,
474483 { self ._func_name !r} ,
475484 { self ._async_func !r} ,
476485 )
486+ \n { epilogue }
487+ return result
477488
478489 try:
479490 result = await __tmp()
@@ -482,6 +493,81 @@ async def __tmp():
482493 result
483494 """
484495
496+ # Hooks for run_in_pyodide_coverage
497+
498+ def _get_code_prelude (self ):
499+ return ""
500+
501+ def _get_code_epilogue (self ):
502+ return ""
503+
504+ def _process_extra (self ):
505+ """Hook to handle any extra data computed.
506+
507+ _get_code_epilogue can add extra data to "result". That extra data will
508+ be passed to this function. See the overload in run_in_pyodide_coverage
509+ for how this is used.
510+ """
511+ pass
512+
513+
514+ # * coverage_count ensures that multiple files from one test run don't overwrite
515+ # each other.
516+ # * coverage_hex prevents us from overwriting files generated by other test
517+ # runs.
518+ _COVERAGE_COUNT = 1
519+ _COVERAGE_HEX = token_hex (20 )
520+
521+
522+ def _get_coverage_path () -> Path :
523+ global _COVERAGE_COUNT
524+ count = _COVERAGE_COUNT
525+ _COVERAGE_COUNT += 1
526+ return Path (f".coverage.emscripten.{ count } .{ _COVERAGE_HEX } " )
527+
528+
529+ class run_in_pyodide_coverage (run_in_pyodide ):
530+ def __init__ (
531+ self ,
532+ packages : Collection [str ] = (),
533+ pytest_assert_rewrites : bool = True ,
534+ * ,
535+ _force_assert_rewrites : bool = False ,
536+ coverage_args : dict [str , Any ] | None = None ,
537+ ):
538+ super ().__init__ (
539+ packages ,
540+ pytest_assert_rewrites ,
541+ _force_assert_rewrites = _force_assert_rewrites ,
542+ )
543+ self ._pkgs .append ("coverage" )
544+ if coverage_args is None :
545+ coverage_args = {}
546+ self ._coverage_args = coverage_args
547+
548+ def _get_code_prelude (self ):
549+ """Start coverage with the coverage_args passed from the host"""
550+ return dedent (
551+ f"""
552+ from pytest_pyodide.decorator import start_coverage
553+ coverage = start_coverage({ _encode (self ._coverage_args )!r} )
554+ """
555+ )
556+
557+ def _get_code_epilogue (self ):
558+ """Stop coverage and append the data to the result"""
559+ return dedent (
560+ """
561+ from pytest_pyodide.decorator import end_coverage
562+ coverage_outdata = end_coverage(coverage)
563+ result = (*result, coverage_outdata)
564+ """
565+ )
566+
567+ def _process_extra (self , coverage_out_binary ): # type:ignore[override]
568+ """Write coverage data to the file system"""
569+ _get_coverage_path ().write_bytes (b64decode (coverage_out_binary ))
570+
485571
486572def copy_files_to_pyodide (file_list , install_wheels = True , recurse_directories = True ):
487573 """A decorator that copies files across to pyodide"""
0 commit comments