"""Runs a learner's program, or an exercise's tests, the way `python main.py` would. One file for both places Python runs: - the browser: the sandboxed worker (src/lib/learn/pyrun/frame.js) imports it into Pyodide; - the content harness: scripts/content-verify.ts runs it with CPython in the python:3.14 image, so a reference solution is proven by the same code that checks a learner's attempt. Standard library only. Command line (harness): python learnrun.py run runs main.py; exits with its status python learnrun.py test runs test_main.py's test_* functions; prints PASS or FAIL per check, writes results.json and exits 0 if all passed """ import json import os import shutil import sys import traceback WORKSPACE = "/home/pyodide/work" def _exit_status(exc): code = exc.code if code is None: return 0 if isinstance(code, int): return code print(code, file=sys.stderr) return 1 # Frames of this file above the learner's code: run_program or run_tests, then _exec_file. OWN_FRAMES = 2 def _format_exception(exc): """The traceback CPython would print, without the frames of this file. A file that does not compile has no frames of its own, and CPython reports it with no "Traceback" header; dropping every frame does the same. """ tb = exc.__traceback__ skip = 99 if isinstance(exc, SyntaxError) and tb is not None and tb.tb_next is not None and tb.tb_next.tb_next is None else OWN_FRAMES for _ in range(skip): if tb is not None: tb = tb.tb_next return "".join(traceback.format_exception(type(exc), exc, tb)).rstrip() def _forget_workspace_modules(): """A second run must import the edited files, not the cached modules (packages included).""" here = os.path.join(os.getcwd(), "") for name, module in list(sys.modules.items()): path = getattr(module, "__file__", None) if path and os.path.abspath(path).startswith(here): del sys.modules[name] def _reset_process_state(): """A run must start as a fresh `python main.py` would, but the browser keeps one interpreter. Modules outside the workspace stay imported, so reset the process-wide state programs commonly set: logging configuration (basicConfig does nothing once the root logger has a handler), sys.argv and typing overloads. """ sys.argv = ["main.py"] # typing.get_overloads() keeps the overloads of every run, keyed by module # name; drop the learner's (run as __main__, tests as test_main, and the # workspace modules just forgotten). A private table, so only if present. typing = sys.modules.get("typing") registry = getattr(typing, "_overload_registry", None) if isinstance(registry, dict): for module in [m for m in registry if m in ("__main__", "test_main") or m not in sys.modules]: del registry[module] logging = sys.modules.get("logging") if logging is None: return logging.disable(logging.NOTSET) loggers = [logging.getLogger()] + [ lg for lg in logging.Logger.manager.loggerDict.values() if isinstance(lg, logging.Logger) ] for logger in loggers: for handler in list(logger.handlers): logger.removeHandler(handler) try: handler.close() except Exception: pass logger.setLevel(logging.NOTSET) logger.propagate = True logger.disabled = False logging.getLogger().setLevel(logging.WARNING) def _exec_file(path, name): with open(path, encoding="utf-8") as f: source = f.read() code = compile(source, path, "exec") namespace = {"__name__": name, "__file__": path, "__builtins__": __builtins__} exec(code, namespace) return namespace def run_program(path="main.py"): """Runs a program as __main__. Returns its exit status.""" _forget_workspace_modules() _reset_process_state() if os.getcwd() not in sys.path: sys.path.insert(0, os.getcwd()) try: _exec_file(path, "__main__") except SystemExit as exc: return _exit_status(exc) except BaseException as exc: print(_format_exception(exc), file=sys.stderr) return 1 finally: sys.stdout.flush() sys.stderr.flush() return 0 def run_main(stdin="", path="main.py"): """For tests: runs the learner's program with this input; returns what it printed. Lets a lesson test a whole program before functions are taught: out = run_main("31\\n") assert out.strip() == "hot", ... Exceptions in the program reach the test, which then fails with their message. """ import contextlib import io printed = io.StringIO() saved = sys.stdin sys.stdin = io.StringIO(stdin) try: with contextlib.redirect_stdout(printed): _exec_file(path, "__main__") finally: sys.stdin = saved return printed.getvalue() # What one check may report, and a load error: output is capped (frame.js), so these are too. MESSAGE_MAX = 2000 ERROR_MAX = 20000 def _cap(text, limit): return text if len(text) <= limit else text[:limit] + " ... (cut)" def _failure(exc, test_file): """One line that says why a test failed: the assert message, else the assert line.""" if isinstance(exc, AssertionError): if exc.args: return str(exc.args[0]) frames = [f for f in traceback.extract_tb(exc.__traceback__) if f.filename == test_file] if frames and frames[-1].line: return "Failed: " + frames[-1].line return "Failed." return traceback.format_exception_only(type(exc), exc)[-1].strip() def run_tests(path="test_main.py"): """Runs every test_* function in path, in the order written. Returns {"ok": bool, "error": str | None, "tests": [{"name", "label", "ok", "message"}]}. A test file that cannot be loaded (usually because main.py has an error) returns ok False with the traceback in "error" and no tests. """ _forget_workspace_modules() _reset_process_state() if os.getcwd() not in sys.path: sys.path.insert(0, os.getcwd()) try: namespace = _exec_file(path, "test_main") except SystemExit as exc: return {"ok": False, "error": "The program called exit() (status %s) while it was imported." % _exit_status(exc), "tests": []} except BaseException as exc: return {"ok": False, "error": _cap(_format_exception(exc), ERROR_MAX), "tests": []} results = [] for name, value in list(namespace.items()): # tests may add globals (__warningregistry__) if not (name.startswith("test_") and callable(value)): continue doc = (value.__doc__ or "").strip().splitlines() label = doc[0].strip() if doc else name try: value() except BaseException as exc: results.append({"name": name, "label": _cap(label, MESSAGE_MAX), "ok": False, "message": _cap(_failure(exc, path), MESSAGE_MAX)}) else: results.append({"name": name, "label": _cap(label, MESSAGE_MAX), "ok": True, "message": ""}) sys.stdout.flush() sys.stderr.flush() return {"ok": bool(results) and all(r["ok"] for r in results), "error": None, "tests": results} def workspace(files): """Browser only: an empty working directory holding exactly these files.""" # Out of it first: the browser's file system will not remove the current directory. os.chdir(os.path.dirname(WORKSPACE)) if os.path.exists(WORKSPACE): shutil.rmtree(WORKSPACE) os.makedirs(WORKSPACE) os.chdir(WORKSPACE) for name, text in files.items(): # A project has folders (a package); a name never leaves the workspace. path = os.path.normpath(os.path.join(WORKSPACE, name)) if not path.startswith(os.path.join(WORKSPACE, "")): raise ValueError("file name outside the workspace: %r" % name) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(text) def tests_json(): """Browser only: run_tests() as JSON, so no Python object crosses into JavaScript.""" return json.dumps(run_tests()) if __name__ == "__main__": mode = sys.argv[1] if len(sys.argv) > 1 else "run" if mode == "run": sys.exit(run_program()) report = run_tests() with open("results.json", "w", encoding="utf-8") as out: json.dump(report, out) # For a learner running the checks locally. ASCII only: a Windows console may not print more. if report["error"]: print(report["error"], file=sys.stderr) for result in report["tests"]: print(("PASS " if result["ok"] else "FAIL ") + result["label"]) if not result["ok"]: print(" " + result["message"]) passed = sum(1 for result in report["tests"] if result["ok"]) print("%d of %d checks passed." % (passed, len(report["tests"]))) sys.exit(0 if report["ok"] else 1)