pll-python 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -1
- package/cli.cjs +1493 -1183
- package/package.json +2 -2
- package/worker.cjs +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pll-python",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Run Python with PLL's language levels from the command line: level checks, in-file tests, friendly errors and the built-in image and table libraries, all on Pyodide. No Python install needed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"node": ">=22"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"pyodide": "
|
|
34
|
+
"pyodide": "0.29.3"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/worker.cjs
CHANGED
|
@@ -161,7 +161,7 @@ function collectChangedWorkspaceFiles(FS) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
// src/common/pyodideBootstrap.py
|
|
164
|
-
var pyodideBootstrap_default = '# Python Language Levels runtime bootstrap.\n#\n# This module is loaded into Pyodide once when the runtime initializes.\n# It exposes four entry points used by the TypeScript host:\n#\n# _pll_run_file(code, filename, session_key, level="raw") -> dict\n# _pll_repl_eval(code, session_key, level="raw") -> dict\n# _pll_repl_check(source) -> dict\n# _pll_has_tests(code) -> bool\n# _pll_run_tests(code, filename, level="raw") -> dict\n# _pll_static_analyze(code, level, filename, session_key=None) -> list[dict]\n#\n# The level is the only knob: it decides both the static checks and whether\n# annotations are instrumented. `raw` is plain Python plus PLL\'s libraries.\n#\n# Each returns a JSON-friendly dict / list of dicts so the JS side can\n# consume the result via `proxy.toJs({ dict_converter: Object.fromEntries })`.\n#\n# Sessions\n# --------\n# Each Python file gets its own session, keyed by an opaque string the host\n# chooses (typically the document URI). Sessions hold their own globals\n# dict, so file A\'s `data = ...` doesn\'t leak into file B\'s REPL prompt.\n# `_pll_run_file` resets the addressed session\'s globals to the baseline\n# template before executing; `_pll_repl_eval` does NOT reset, so REPL\n# input keeps the names defined by the most recent Run File of the same\n# session.\n\nimport traceback as _tb_mod\nimport ast as _ast\nimport copy as _pll_copy\nimport codeop as _codeop\nimport contextlib\nimport json as _pll_json\nimport sys as _sys\nimport builtins as _builtins_mod\n\n# Must match PLL_WORK_DIR in memfsWorkspace.ts. Sibling files are mounted\n# here and it is cwd, so open("cars.csv") works. It must not sit first on\n# sys.path or a neighboring pandas.py wins over the real package.\n_PLL_WORK_DIR = "/home/pyodide/pll_workspace"\n\n# Vendored pure-Python wheels (typeguard + typing_extensions) that the\n# worker writes into MEMFS before calling `_pll_enable_type_checking`.\n# Must match PLL_VENDOR_DIR in pythonVendor.ts.\n_PLL_VENDOR_DIR = "/pll_vendor"\n\n# Set by `_pll_enable_type_checking`. While False, `_pll_instrument_types`\n# leaves code untouched, so a program still runs - just unchecked.\n_PLL_TYPEGUARD_READY = False\n_pll_typeguard_transformer = None\n_pll_type_check_error = None\n\n# Whether a bool is rejected where int / float is annotated. Set per run\n# from the language level; see `levelRejectsBoolAsNumber` in level.ts.\n# Python counts True as 1 and both mypy and typeguard follow it, which is a\n# hole at the teaching levels: a student who annotates `int` and passes\n# `True` has almost always made a mistake. `advanced` keeps Python\'s rule.\n_PLL_STRICT_NUMBERS = False\n\n# Whether annotations are instrumented at all. Set per run from the language\n# level; see `levelHasTypeChecking` in level.ts. False only at `#level raw`,\n# which exists so a file can opt out. There is no separate setting: the level\n# is the single input, so nothing can disagree with it.\n_PLL_TYPE_CHECK = False\n\n\ndef _pll_check_strict_int(value, origin_type, args, memo):\n """Replaces typeguard\'s `int` check; bools are not whole numbers here.\n\n The wording matches typeguard\'s own so the host-side explainer needs\n no special case: it sees the actual type (bool) and the expected one.\n """\n if isinstance(value, bool) or not isinstance(value, int):\n raise _pll_type_check_error("is not an instance of int")\n\n\ndef _pll_check_strict_float(value, origin_type, args, memo):\n """As `_pll_check_strict_int`, for the int-or-float numeric tower."""\n if isinstance(value, bool) or not isinstance(value, (int, float)):\n raise _pll_type_check_error("is neither float or int")\n\n\ndef _pll_strict_number_lookup(origin_type, args, extras):\n """typeguard checker lookup; consulted on every check, so the flag\n can be flipped per run without re-registering."""\n if not _PLL_STRICT_NUMBERS:\n return None\n if origin_type is int:\n return _pll_check_strict_int\n if origin_type is float:\n return _pll_check_strict_float\n return None\n\n\ndef _pll_apply_level(level):\n """Set the per-run strictness implied by the language level.\n\n The level is the only input. `raw` checks nothing; `advanced` checks\n annotations by Python\'s own rules; the two teaching levels additionally\n reject a bool where a number is annotated. Must agree with level.ts.\n """\n global _PLL_STRICT_NUMBERS, _PLL_TYPE_CHECK\n _PLL_TYPE_CHECK = level != "raw"\n _PLL_STRICT_NUMBERS = level in ("beginner", "intermediate")\n\n\ndef _pll_enable_type_checking():\n """Put the vendored wheels on sys.path and import typeguard.\n\n Returns True when runtime type checking is available. A failure here\n is deliberately not fatal: students\' code must still run, so the\n feature degrades to "no type checks" rather than breaking the editor.\n """\n global _PLL_TYPEGUARD_READY, _pll_typeguard_transformer, _pll_type_check_error\n if _PLL_TYPEGUARD_READY:\n return True\n try:\n for name in ("typing_extensions.whl", "typeguard.whl"):\n path = _PLL_VENDOR_DIR + "/" + name\n # Appended, not prepended: a real package in site-packages\n # (Pyodide ships typing_extensions too) should still win.\n if path not in _sys.path:\n _sys.path.append(path)\n from typeguard import TypeCheckError, TypeCheckMemo\n from typeguard._checkers import checker_lookup_functions\n from typeguard._config import CollectionCheckStrategy, global_config\n from typeguard._functions import check_variable_assignment\n from typeguard._transformer import TypeguardTransformer\n\n # `list[int]` should mean *every* item is an int. typeguard only\n # checks the first item by default, which silently accepts\n # [1, 2, "three"] - confusing when the annotation says otherwise.\n global_config.collection_check_strategy = CollectionCheckStrategy.ALL_ITEMS\n\n # Referenced by name in the AST that `_PllTopLevelAnnAssign`\n # injects, so they have to be visible in user globals.\n _pll_initial_globals["_pll_tg_memo"] = TypeCheckMemo\n _pll_initial_globals["_pll_tg_check_assign"] = check_variable_assignment\n # Consulted before typeguard\'s builtin lookup, and gated on\n # `_PLL_STRICT_NUMBERS` so it is a no-op at `advanced`.\n _pll_type_check_error = TypeCheckError\n if _pll_strict_number_lookup not in checker_lookup_functions:\n checker_lookup_functions.insert(0, _pll_strict_number_lookup)\n\n _pll_typeguard_transformer = TypeguardTransformer\n _PLL_TYPEGUARD_READY = True\n except BaseException:\n _PLL_TYPEGUARD_READY = False\n return _PLL_TYPEGUARD_READY\n\n\ndef _pll_is_vendor_frame(filename):\n return isinstance(filename, str) and filename.startswith(_PLL_VENDOR_DIR)\n\n\ndef _pll_format_exception(exc):\n """`format_exception`, minus frames inside the vendored type checker.\n\n A typeguard failure otherwise ends in several frames of typeguard\'s\n own checker, burying the student\'s line under machinery they did not\n write. When nothing is dropped this returns the stdlib formatting\n unchanged, so ordinary errors look exactly as they did before.\n """\n try:\n frames = _tb_mod.extract_tb(exc.__traceback__)\n kept = [f for f in frames if not _pll_is_vendor_frame(f.filename)]\n if len(kept) == len(frames):\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n parts = ["Traceback (most recent call last):\\n"]\n parts.extend(_tb_mod.StackSummary.from_list(kept).format())\n parts.extend(_tb_mod.format_exception_only(type(exc), exc))\n return "".join(parts)\n except BaseException:\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n\n\ndef _pll_protect_import_path():\n """Keep sibling .py files from shadowing installed packages.\n\n Python puts \'\' (cwd) first on sys.path. After chdir to the work dir,\n import pandas would load a mounted pandas.py. Drop the empty entry,\n append the work dir so unique sibling modules still import, and evict\n anything already loaded from that folder (a failed pandas.py import\n leaves a poisoned sys.modules entry).\n """\n while "" in _sys.path:\n _sys.path.remove("")\n while _PLL_WORK_DIR in _sys.path:\n _sys.path.remove(_PLL_WORK_DIR)\n _sys.path.append(_PLL_WORK_DIR)\n prefix = _PLL_WORK_DIR + "/"\n for name, mod in list(_sys.modules.items()):\n filename = getattr(mod, "__file__", None)\n if isinstance(filename, str) and filename.startswith(prefix):\n _sys.modules.pop(name, None)\n\n# Per-session globals dicts, keyed by session_key (e.g. document URI).\n# Created lazily; initialized from `_pll_initial_globals`.\n_pll_sessions = {}\n\n# The "template" globals used to seed each new session and to reset a\n# session at the start of every Run File. Populated by PYODIDE_INSTALL_PY\n# at the end of bootstrap so the template includes the PLL image\n# library + the auto-display helper.\n_pll_initial_globals = {"__name__": "__main__", "__builtins__": __builtins__}\n\n# Display emissions captured during the most recent `_pll_run_file` /\n# `_pll_repl_eval` call. The host drains this list after the call.\n# Each entry is a typed dict (`{"type": "stdout"|"stderr", "text": ...}`,\n# `{"type": "image", ...}`, or `{"type": "table", ...}`) so the host can\n# dispatch by kind while preserving the *exact* order in which the user\'s\n# code produced them - that way a `print()` followed by a top-level table\n# shows up as text-then-table in the interactions view, even though\n# stdout and image/table emissions take different paths inside Python.\n# Pyodide is single-threaded, so a single shared list is fine.\n_pll_displays = []\n\n# When set (by either host during a file run), this is a JS callback taking\n# one JSON string. Every display payload \u2014 each stdout/stderr write, image,\n# and table \u2014 is emitted through it as it is produced, so the interactions\n# view updates *during* the run instead of only at the end. This is what\n# lets an interactive program print a prompt before input() blocks.\n# Left as None during REPL/tests, where output is delivered in one batch.\n_pll_live_emit = None\n\n\ndef _pll_push(payload):\n """Emit a display payload live, or record it for the end of the run.\n\n Exactly one of the two: when a live hook is installed the host streams\n each payload as it happens and then discards `result["displays"]`\n (see `withLiveEmit` / the `runFile` case in workerHost.ts), so also\n accumulating them costs memory and a large FFI conversion for a list\n nobody reads. A program printing in a loop built a multi-million entry\n list that was copied and converted to JS purely to be dropped.\n """\n emit = _pll_live_emit\n if emit is None:\n _pll_displays.append(payload)\n return\n try:\n emit(_pll_json.dumps(payload))\n except Exception:\n pass\n\n\nclass _PllStream:\n """Drop-in replacement for `sys.stdout` / `sys.stderr` during a run.\n\n Each `write` pushes a typed entry onto `_pll_displays` so text\n output interleaves with image/table emissions. We also keep the\n aggregate string so `result["stdout"]` / `result["stderr"]` can\n still be inspected by smoke tests and any caller that just wants\n "what did the program print?".\n """\n\n __slots__ = ("_kind", "_chunks")\n\n def __init__(self, kind):\n self._kind = kind # "stdout" or "stderr"\n self._chunks = []\n\n def write(self, s):\n if not isinstance(s, str):\n s = str(s)\n if s:\n _pll_push({"type": self._kind, "text": s})\n self._chunks.append(s)\n return len(s)\n\n def writelines(self, lines):\n for line in lines:\n self.write(line)\n\n def flush(self):\n pass\n\n def isatty(self):\n return False\n\n def getvalue(self):\n return "".join(self._chunks)\n\n\ndef _pll_extract_display(value):\n """Try every known display protocol on `value`.\n\n Returns a typed payload (`{"type": "image"|"table", ...}`) or None if\n `value` doesn\'t know how to display itself.\n """\n if hasattr(value, "_pll_table_data"):\n try:\n data = value._pll_table_data()\n except Exception:\n return None\n if isinstance(data, dict):\n data = dict(data)\n data["type"] = "table"\n return data\n if hasattr(value, "_pll_image_data"):\n try:\n data = value._pll_image_data()\n except Exception:\n return None\n # _pll_image_data historically uses {"type": "svg", ...}\n # internally; promote to the unified outer type.\n if isinstance(data, dict):\n payload = dict(data)\n payload["type"] = "image"\n payload["format"] = data.get("type", "svg")\n return payload\n return None\n\n\ndef _pll_show_top_level(value):\n """Emit a value produced by a top-level expression statement.\n\n Mirrors the behavior of Python\'s interactive shell: `None` is suppressed,\n PLL images and tables are captured for the host to render, anything\n else is printed via `repr` so bare expressions like `1 + 2` still display.\n """\n if value is None:\n return\n # A reactor pushed its own card when it was started; printing its repr\n # as well would be noise. Duck-typed like the display protocols, so the\n # bootstrap still knows nothing about reactorLib.\n if getattr(value, "_pll_already_displayed", False):\n return\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n return\n print(repr(value))\n\n\n# Seed the template with the auto-display helper. The image library names\n# get added later by PYODIDE_INSTALL_PY.\n_pll_initial_globals["_pll_show_top_level"] = _pll_show_top_level\n\n\ndef _pll_get_session(session_key):\n """Get-or-create the globals dict for `session_key`.\n\n Newly-created sessions start as a copy of `_pll_initial_globals`\n (so all baseline names like the image primitives are present).\n """\n g = _pll_sessions.get(session_key)\n if g is None:\n g = dict(_pll_initial_globals)\n _pll_sessions[session_key] = g\n return g\n\n\ndef _pll_reset_session(session_key):\n """Reset the globals for `session_key` to the baseline template.\n\n Mutates the existing dict in place (`clear` + `update`) so any cached\n reference to it (e.g. from `_pll_show_top_level`\'s closure or from\n Pyodide\'s `globals.get(...)`) remains valid.\n """\n g = _pll_get_session(session_key)\n g.clear()\n g.update(_pll_initial_globals)\n return g\n\n\nclass _PllTopLevelAnnAssign(_ast.NodeTransformer):\n """Type-check annotated assignments outside functions.\n\n typeguard instruments function arguments, return values, and\n annotated assignments *inside* functions, but leaves\n `total: int = "oops"` at module level unchecked. Students write those,\n so we wrap the value in typeguard\'s own check to get the same wording.\n\n Function and class bodies are skipped: typeguard already covers\n functions, and a bare `name: str` in a class body is a field\n declaration (dataclasses, etc.) rather than an assignment.\n """\n\n def visit_FunctionDef(self, node):\n return node\n\n visit_AsyncFunctionDef = visit_FunctionDef\n\n def visit_ClassDef(self, node):\n return node\n\n def visit_AnnAssign(self, node):\n if node.value is None or not isinstance(node.target, _ast.Name):\n return node\n node.value = self._checked(node.target.id, node.value, node.annotation)\n return node\n\n def _checked(self, name, value, annotation):\n """`value` -> `_pll_tg_check_assign(value, [(name, ann)], memo)`."""\n memo = _ast.Call(\n func=_ast.Name(id="_pll_tg_memo", ctx=_ast.Load()),\n args=[\n _ast.Call(func=_ast.Name(id="globals", ctx=_ast.Load()), args=[], keywords=[]),\n _ast.Call(func=_ast.Name(id="locals", ctx=_ast.Load()), args=[], keywords=[]),\n ],\n keywords=[],\n )\n target = _ast.Tuple(\n elts=[_ast.Constant(value=name), _pll_copy.deepcopy(annotation)],\n ctx=_ast.Load(),\n )\n call = _ast.Call(\n func=_ast.Name(id="_pll_tg_check_assign", ctx=_ast.Load()),\n args=[value, _ast.List(elts=[target], ctx=_ast.Load()), memo],\n keywords=[],\n )\n _ast.copy_location(call, value)\n _ast.fix_missing_locations(call)\n return call\n\n\ndef _pll_parse_and_instrument(code, filename):\n """Parse `code`, adding runtime type checks when they are available.\n\n Whether to check at all comes from `_PLL_TYPE_CHECK`, which\n `_pll_apply_level` sets from the level before this is called.\n\n Instrumentation is attempted on a second parse and validated by\n compiling it, so anything typeguard cannot handle falls back to the\n plain tree rather than failing the run.\n """\n tree = _ast.parse(code, filename=filename, mode="exec")\n if not _PLL_TYPE_CHECK or not _PLL_TYPEGUARD_READY:\n return tree\n try:\n instrumented = _ast.parse(code, filename=filename, mode="exec")\n _pll_typeguard_transformer().visit(instrumented)\n _PllTopLevelAnnAssign().visit(instrumented)\n _ast.fix_missing_locations(instrumented)\n compile(instrumented, filename, "exec")\n return instrumented\n except BaseException:\n return tree\n\n\nclass _PllTopLevelExprWrapper(_ast.NodeTransformer):\n """Wrap module-level expression statements so they auto-display.\n\n Skips the conventional module docstring (a string literal as the first\n statement) and bare `None` / `...` constants which are usually noise.\n """\n\n def visit_Module(self, node):\n new_body = []\n for i, stmt in enumerate(node.body):\n if isinstance(stmt, _ast.Expr) and not _pll_should_skip_expr(stmt, i):\n call = _ast.Call(\n func=_ast.Name(id="_pll_show_top_level", ctx=_ast.Load()),\n args=[stmt.value],\n keywords=[],\n )\n wrapped = _ast.Expr(value=call)\n _ast.copy_location(wrapped, stmt)\n _ast.fix_missing_locations(wrapped)\n new_body.append(wrapped)\n else:\n new_body.append(stmt)\n node.body = new_body\n return node\n\n\ndef _pll_should_skip_expr(stmt, index):\n value = stmt.value\n if isinstance(value, _ast.Constant):\n if index == 0 and isinstance(value.value, str):\n return True # module docstring\n if value.value is None or value.value is Ellipsis:\n return True\n return False\n\n\n# -----------------------------------------------------------------------------\n# REPL syntax check (codeop.compile_command in \'single\' mode)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_check(source):\n """Return whether \'source\' is a complete REPL input.\n\n Mirrors what CPython\'s interactive shell does: uses codeop.compile_command\n in \'single\' mode, which returns None for incomplete input (e.g. open\n parens, unfinished block) and raises SyntaxError for actually-broken code.\n """\n try:\n result = _codeop.compile_command(source, "<repl>", "single")\n except (SyntaxError, OverflowError, ValueError) as e:\n return {\n "status": "invalid",\n "error_type": type(e).__name__,\n "message": str(e),\n "lineno": getattr(e, "lineno", None) or 0,\n "offset": (e.offset - 1) if isinstance(e, SyntaxError) and e.offset else 0,\n }\n if result is None:\n return {"status": "incomplete"}\n return {"status": "complete"}\n\n\n# -----------------------------------------------------------------------------\n# Run a whole file (with output capture and traceback extraction)\n# -----------------------------------------------------------------------------\n\ndef _pll_extract_loc(tb_str, fallback_filename):\n line_no = None\n col = None\n for raw in reversed(tb_str.splitlines()):\n line = raw.strip()\n if line.startswith(\'File "\') and ", line " in line:\n try:\n rest = line.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n line_no = None\n break\n if line_no is None and fallback_filename:\n for raw in tb_str.splitlines():\n if fallback_filename in raw and ", line " in raw:\n try:\n rest = raw.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n pass\n break\n return line_no, col\n\n\ndef _pll_run_file(code, filename, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n # Each Run File starts with a clean slate for this session: discard any\n # names defined by a previous Run File of the same session or by REPL\n # exploration since then.\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_reset_session(session_key)\n _pll_displays.clear()\n try:\n tree = _pll_parse_and_instrument(code, filename)\n _PllTopLevelExprWrapper().visit(tree)\n _ast.fix_missing_locations(tree)\n compiled = compile(tree, filename, "exec")\n except SyntaxError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = tb_text\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, user_globals)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# Tests (pytest, same-file test_* / Test* collection)\n# -----------------------------------------------------------------------------\n\ndef _pll_has_tests(code):\n """Return True if `code` looks like it contains pytest tests.\n\n Mirrors pytest\'s default collection: module-level `test_*` functions\n and `test_*` methods on `Test*` classes. Used so we can skip loading\n pytest for files that have no tests.\n """\n try:\n tree = _ast.parse(code)\n except (SyntaxError, ValueError):\n return False\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n return True\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n return True\n return False\n\n\n_PLL_TRACE_SKIP = (\n _PLL_VENDOR_DIR,\n "_pytest",\n "pluggy",\n "site-packages",\n "/lib/python",\n "get_terminal_writer",\n "pyio.py",\n "pathlib",\n)\n\n\ndef _pll_is_internal_frame(line):\n return any(tok in line for tok in _PLL_TRACE_SKIP)\n\n\ndef _pll_fix_ast_ranges(tree):\n """Make rewritten asserts compile on Python 3.12+.\n\n `rewrite_asserts` can leave `end_lineno` < `lineno` on injected nodes.\n CPython then raises `ValueError: AST node line range (...) is not valid`.\n """\n for child in _ast.walk(tree):\n lineno = getattr(child, "lineno", None)\n end_lineno = getattr(child, "end_lineno", None)\n if lineno is not None and end_lineno is not None and end_lineno < lineno:\n child.end_lineno = lineno\n col = getattr(child, "col_offset", None)\n end_col = getattr(child, "end_col_offset", None)\n if (\n col is not None\n and end_col is not None\n and lineno is not None\n and end_lineno is not None\n and lineno == end_lineno\n and end_col < col\n ):\n child.end_col_offset = col\n\n\ndef _pll_test_locations(tree):\n """Map collected test names to 1-based source lines."""\n locs = {}\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n locs[node.name] = node.lineno\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n locs[node.name + "::" + item.name] = item.lineno\n return locs\n\n\ndef _pll_friendly_assert_message(exc, tb_text):\n """Short explanation of a failed assert, with pytest internals stripped."""\n msg = str(exc).strip()\n lines = []\n for raw in msg.split("\\n"):\n stripped = raw.strip()\n if not stripped:\n continue\n if _pll_is_internal_frame(stripped):\n continue\n if stripped.startswith("+"):\n stripped = stripped.lstrip("+ ").strip()\n lines.append(stripped)\n if lines:\n return "\\n".join(lines)\n for raw in reversed((tb_text or "").splitlines()):\n stripped = raw.strip()\n if stripped.startswith("assert "):\n return stripped\n return "This test failed."\n\n\ndef _pll_is_async(fn):\n try:\n import inspect\n return inspect.iscoroutinefunction(fn)\n except Exception:\n return False\n\n\ndef _pll_call_test(fn):\n """Run one test function. Returns (outcome, message, stdout)."""\n import io\n\n buf = io.StringIO()\n try:\n with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):\n if _pll_is_async(fn):\n return (\n "error",\n "async tests are not supported.",\n buf.getvalue().strip() or None,\n )\n fn()\n return ("passed", None, buf.getvalue().strip() or None)\n except AssertionError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n return (\n "failed",\n _pll_friendly_assert_message(e, tb_text),\n buf.getvalue().strip() or None,\n )\n except BaseException as e:\n name = type(e).__name__\n if name == "Skipped":\n return ("skipped", str(e).strip() or None, buf.getvalue().strip() or None)\n return (\n "error",\n name + ": " + (str(e) or "this test raised an exception."),\n buf.getvalue().strip() or None,\n )\n\n\ndef _pll_iter_tests(ns):\n """Yield (name, callable) for pytest-style tests in namespace `ns`."""\n items = list(ns.items())\n for name, obj in items:\n if name.startswith("test_") and callable(obj) and not isinstance(obj, type):\n yield name, obj\n for name, obj in items:\n if not (isinstance(obj, type) and name.startswith("Test")):\n continue\n # pytest skips classes with a custom constructor.\n try:\n obj()\n except TypeError:\n continue\n for meth_name in dir(obj):\n if not meth_name.startswith("test_"):\n continue\n attr = getattr(obj, meth_name, None)\n if not callable(attr):\n continue\n\n def _bound(cls=obj, method=meth_name):\n return getattr(cls(), method)()\n\n yield name + "::" + meth_name, _bound\n\n\ndef _pll_run_tests(code, filename, level="raw"):\n """Run same-file tests (`test_*` / `Test*`) in an isolated namespace.\n\n Uses pytest only to rewrite assertions so failures show `assert 4 == 5`\n instead of an empty AssertionError. Does **not** call `pytest.main()`,\n which is not safe to invoke repeatedly in one Pyodide interpreter.\n """\n display_name = filename.rsplit("/", 1)[-1].rsplit("\\\\", 1)[-1] or "user_script.py"\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "internal_error": False,\n "passed": 0,\n "failed": 0,\n "skipped": 0,\n "errors": 0,\n "tests": [],\n "stdout": "",\n "stderr": "",\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n\n try:\n tree = _pll_parse_and_instrument(code, display_name)\n except SyntaxError as e:\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n locs = _pll_test_locations(tree)\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n rewrite_asserts(tree, code.encode("utf-8"), module_path=display_name)\n # Do not call ast.fix_missing_locations here: it copies parent\n # positions onto pytest\'s injected nodes and yields ranges that\n # Python 3.12+ rejects (`end_lineno` < `lineno`).\n _pll_fix_ast_ranges(tree)\n compiled = compile(tree, display_name, "exec")\n except Exception:\n # Assert rewriting failed; keep the type instrumentation.\n compiled = compile(tree, display_name, "exec")\n\n ns = dict(_pll_initial_globals)\n ns["__name__"] = "__pll_test__"\n ns["__file__"] = display_name\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, ns)\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, display_name)\n result["line_number"] = line_no\n result["column"] = col\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n rows = []\n passed = failed = errors = skipped = 0\n for name, fn in _pll_iter_tests(ns):\n outcome, message, cap = _pll_call_test(fn)\n if outcome == "passed":\n passed += 1\n elif outcome == "failed":\n failed += 1\n elif outcome == "skipped":\n skipped += 1\n else:\n errors += 1\n rows.append({\n "name": name,\n "outcome": outcome,\n "line_number": locs.get(name),\n "message": message,\n "stdout": cap,\n })\n\n result["passed"] = passed\n result["failed"] = failed\n result["skipped"] = skipped\n result["errors"] = errors\n result["tests"] = rows\n result["ok"] = failed == 0 and errors == 0\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# REPL-style eval (statements + last-expression value)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_eval(code, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_get_session(session_key)\n filename = "<repl>"\n try:\n tree = _pll_parse_and_instrument(code, filename)\n except SyntaxError as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n last_expr = None\n if tree.body and isinstance(tree.body[-1], _ast.Expr):\n last_expr = tree.body[-1]\n tree.body = tree.body[:-1]\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n if tree.body:\n compiled_stmts = compile(tree, filename, "exec")\n exec(compiled_stmts, user_globals)\n if last_expr is not None:\n expr_module = _ast.Expression(body=last_expr.value)\n compiled_expr = compile(expr_module, filename, "eval")\n value = eval(compiled_expr, user_globals)\n if value is not None:\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n else:\n result["result_repr"] = repr(value)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# =============================================================================\n# Static analysis (beginner / intermediate level checks)\n# =============================================================================\n#\n# Per-level rules:\n#\n# beginner:\n# 1. Shadowing: a binding whose name appears in any enclosing\n# scope or is the name of a Python built-in.\n# 2. Reassignment: a name bound more than once within the *same*\n# scope. Suppressed for names already flagged as\n# shadowing in that scope (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# intermediate:\n# 1. Shadowing: same as beginner.\n# 2. Reassignment: only flagged at module scope. Function/lambda/\n# class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator\n# patterns (e.g. `total = 0; for x in xs: total += x`\n# inside `def`).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# advanced:\n# No checks. Full Python.\n#\n# A "scope" is one of: module, function (incl. async), lambda, class,\n# comprehension/generator. We model these explicitly because Python 3\n# comprehensions have their own scope.\n\n\n_PLL_SCOPE_FUNC = (_ast.FunctionDef, _ast.AsyncFunctionDef)\n_PLL_SCOPE_COMP = (_ast.ListComp, _ast.SetComp, _ast.DictComp, _ast.GeneratorExp)\n\n# Attributes `dir()` reports for the `builtins` *module object* itself. They\n# are not built-in functions, so reporting them as shadowed would be a false\n# positive with misleading wording ("`__name__` is the name of a Python\n# built-in"). Everything else `dir()` returns is a real builtin and is worth\n# flagging, including the dunder ones like `__import__`.\n_PLL_BUILTINS_MODULE_META = frozenset(\n ("__doc__", "__loader__", "__name__", "__package__", "__spec__")\n)\n\n\nclass _PllScope:\n __slots__ = ("node", "kind", "parent", "bindings")\n\n def __init__(self, node, kind, parent):\n self.node = node\n self.kind = kind # "module" | "function" | "lambda" | "class" | "comprehension"\n self.parent = parent # _PllScope | None\n self.bindings = {} # name -> [(lineno, col, kind), ...]\n\n\ndef _pll_arg_names(args):\n """All argument names (with positions) on an ast.arguments node."""\n out = []\n posonly = list(getattr(args, "posonlyargs", []) or [])\n for a in posonly + list(args.args) + list(args.kwonlyargs):\n out.append((a.arg, a.lineno, a.col_offset))\n if args.vararg:\n out.append((args.vararg.arg, args.vararg.lineno, args.vararg.col_offset))\n if args.kwarg:\n out.append((args.kwarg.arg, args.kwarg.lineno, args.kwarg.col_offset))\n return out\n\n\nclass _PllScopeBuilder:\n """Walk a module AST and build a list of scopes with their bindings.\n\n A binding is collected in the *enclosing* scope of the syntactic node\n (e.g. a `def f` adds `f` to the surrounding scope and creates a new\n function scope for its body). Comprehensions, lambdas, and classes\n each open their own scope.\n """\n\n def __init__(self):\n self.scopes = []\n\n def build(self, tree):\n module = _PllScope(tree, "module", None)\n self.scopes.append(module)\n for stmt in tree.body:\n self._walk(stmt, module)\n return module\n\n def _add(self, scope, name, lineno, col, kind):\n scope.bindings.setdefault(name, []).append((lineno, col, kind))\n\n def _add_target(self, target, scope, kind):\n if isinstance(target, _ast.Name):\n self._add(scope, target.id, target.lineno, target.col_offset, kind)\n elif isinstance(target, (_ast.Tuple, _ast.List)):\n for elt in target.elts:\n self._add_target(elt, scope, kind)\n elif isinstance(target, _ast.Starred):\n self._add_target(target.value, scope, kind)\n else:\n # Subscript/Attribute targets are not name bindings.\n self._walk(target, scope)\n\n def _walk(self, node, scope):\n # --- Binding-creating statements ---------------------------------\n if isinstance(node, _ast.Assign):\n for t in node.targets:\n self._add_target(t, scope, "assign")\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AnnAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "annassign")\n else:\n self._walk(node.target, scope)\n if node.annotation is not None:\n self._walk(node.annotation, scope)\n if node.value is not None:\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AugAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "augassign")\n else:\n self._walk(node.target, scope)\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.For, _ast.AsyncFor)):\n self._add_target(node.target, scope, "for")\n self._walk(node.iter, scope)\n for s in node.body:\n self._walk(s, scope)\n for s in node.orelse:\n self._walk(s, scope)\n return\n if isinstance(node, (_ast.With, _ast.AsyncWith)):\n for item in node.items:\n self._walk(item.context_expr, scope)\n if item.optional_vars is not None:\n self._add_target(item.optional_vars, scope, "with")\n for s in node.body:\n self._walk(s, scope)\n return\n if isinstance(node, _ast.Import):\n for alias in node.names:\n name = alias.asname or alias.name.split(".", 1)[0]\n self._add(scope, name, node.lineno, node.col_offset, "import")\n return\n if isinstance(node, _ast.ImportFrom):\n for alias in node.names:\n name = alias.asname or alias.name\n self._add(scope, name, node.lineno, node.col_offset, "importfrom")\n return\n if isinstance(node, _ast.NamedExpr):\n # Walrus :=\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "walrus")\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n # These don\'t create bindings.\n return\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n self._add(scope, node.name, node.lineno, node.col_offset, "functiondef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "function", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _ast.Lambda):\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "lambda", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n self._walk(node.body, inner)\n return\n if isinstance(node, _ast.ClassDef):\n self._add(scope, node.name, node.lineno, node.col_offset, "classdef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for b in node.bases:\n self._walk(b, scope)\n for kw in node.keywords:\n self._walk(kw.value, scope)\n inner = _PllScope(node, "class", scope)\n self.scopes.append(inner)\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _PLL_SCOPE_COMP):\n inner = _PllScope(node, "comprehension", scope)\n self.scopes.append(inner)\n # Per Python semantics: outermost iter is evaluated in the OUTER\n # scope, everything else inside the comp scope.\n for i, gen in enumerate(node.generators):\n if i == 0:\n self._walk(gen.iter, scope)\n else:\n self._walk(gen.iter, inner)\n self._add_target(gen.target, inner, "comprehension")\n for cond in gen.ifs:\n self._walk(cond, inner)\n if isinstance(node, _ast.DictComp):\n self._walk(node.key, inner)\n self._walk(node.value, inner)\n else:\n self._walk(node.elt, inner)\n return\n\n # --- Default: descend in the same scope --------------------------\n for child in _ast.iter_child_nodes(node):\n self._walk(child, scope)\n\n\nclass _PllKeywordVisitor(_ast.NodeVisitor):\n """Collect every `global` / `nonlocal` statement in a tree.\n\n Each entry is `(keyword, lineno, col_offset, names)` where `keyword`\n is the literal string "global" or "nonlocal" and `names` is the list\n of identifiers the statement applies to.\n """\n\n def __init__(self):\n self.found = []\n\n def visit_Global(self, node):\n self.found.append(("global", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n def visit_Nonlocal(self, node):\n self.found.append(("nonlocal", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n\ndef _pll_session_bound_names(session_key):\n """User-defined names already bound in a session (for REPL checks).\n\n Baseline names from `_pll_initial_globals` (image primitives, etc.) are\n skipped unless the user rebound them, so prompt analysis matches file\n analysis for the same snippet.\n """\n g = _pll_sessions.get(session_key)\n if not g:\n return []\n names = []\n for n in g:\n if n.startswith("_") or n in ("__builtins__", "__name__", "__doc__"):\n continue\n if n in _pll_initial_globals and g[n] is _pll_initial_globals[n]:\n continue\n names.append(n)\n return names\n\n\ndef _pll_static_analyze(code, level, filename, session_key=None):\n """Run static checks for `level` over `code` and return findings.\n\n Returns a list of dicts. Each dict has at minimum:\n id, error_type, message, line_number, column, name_token, scope_kind.\n\n If `session_key` is set, names already bound in that session are treated\n as existing module-level bindings. That way a `#level beginner` prompt cannot\n reassign a name the file (or an earlier prompt line) already defined.\n """\n if level not in ("beginner", "intermediate"):\n return []\n try:\n tree = _ast.parse(code, filename=filename)\n except SyntaxError:\n # Let the runtime path surface SyntaxErrors with their normal flow.\n return []\n\n findings = []\n builder = _PllScopeBuilder()\n builder.build(tree)\n if session_key:\n module = builder.scopes[0]\n for name in _pll_session_bound_names(session_key):\n locs = module.bindings.setdefault(name, [])\n locs.insert(0, (0, 0, "preexisting"))\n builtins_set = set(dir(_builtins_mod)) - _PLL_BUILTINS_MODULE_META\n\n # Whether we flag reassignment in `scope` at this level. At beginner,\n # we flag everywhere; at intermediate, only at module scope so that\n # function-local accumulator patterns (`total = 0; for x in xs:\n # total += x`) work.\n def reassignment_active(scope_kind):\n if level == "beginner":\n return True\n if level == "intermediate":\n return scope_kind == "module"\n return False\n\n for scope in builder.scopes:\n # Names visible from any enclosing scope, paired with the *nearest*\n # outer binding\'s location and scope kind. We walk parents inner-to-\n # outer and refuse to overwrite, so the closest enclosing binding wins\n # (which is the one Python\'s lookup rules would resolve to).\n enclosing = {} # name -> (lineno, col, scope_kind)\n cur = scope.parent\n while cur is not None:\n for outer_name, outer_locs in cur.bindings.items():\n if outer_name in enclosing:\n continue\n outer_first = outer_locs[0]\n enclosing[outer_name] = (outer_first[0], outer_first[1], cur.kind)\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n first_loc = locs[0]\n if name in enclosing:\n shadowed_in_scope.add(name)\n outer = enclosing[name]\n findings.append({\n "id": "shadowing",\n "error_type": "Shadowing",\n "message": "`%s` is already defined in an outer scope" % name,\n "line_number": first_loc[0],\n "column": first_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "outer_line_number": outer[0],\n "outer_column": outer[1],\n "outer_scope_kind": outer[2],\n })\n elif name in builtins_set:\n shadowed_in_scope.add(name)\n findings.append({\n "id": "shadowing-builtin",\n "error_type": "Shadowing",\n "message": "`%s` is the name of a Python built-in" % name,\n "line_number": first_loc[0],\n "column": first_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n })\n\n # ---- Then reassignment (skip names already shadow-flagged) ----\n if reassignment_active(scope.kind):\n for name, locs in scope.bindings.items():\n if name in shadowed_in_scope:\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n findings.append({\n "id": "reassignment",\n "error_type": "Reassignment",\n "message": "`%s` is assigned more than once in this scope" % name,\n "line_number": second_loc[0],\n "column": second_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "first_line_number": first_loc[0],\n "first_column": first_loc[1],\n })\n\n # ---- `global` / `nonlocal` keyword check ----\n # Both are disallowed at beginner and intermediate. We emit one finding\n # per declaration (not per name) so a `global x, y` produces a single\n # diagnostic on that line.\n kw_visitor = _PllKeywordVisitor()\n kw_visitor.visit(tree)\n for keyword, lineno, col, names in kw_visitor.found:\n primary = names[0] if names else ""\n findings.append({\n "id": "disallowed-keyword",\n "error_type": "DisallowedKeyword",\n "message": "`%s` is not allowed at the %s level" % (keyword, level),\n "line_number": lineno,\n "column": col,\n "name_token": primary,\n "scope_kind": "function",\n "keyword": keyword,\n "names": list(names),\n })\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
|
|
164
|
+
var pyodideBootstrap_default = '# Python Language Levels runtime bootstrap.\n#\n# This module is loaded into Pyodide once when the runtime initializes.\n# It exposes four entry points used by the TypeScript host:\n#\n# _pll_run_file(code, filename, session_key, level="raw") -> dict\n# _pll_repl_eval(code, session_key, level="raw") -> dict\n# _pll_repl_check(source) -> dict\n# _pll_has_tests(code) -> bool\n# _pll_run_tests(code, filename, level="raw") -> dict\n# _pll_static_analyze(code, level, filename, session_key=None) -> list[dict]\n#\n# The level is the only knob: it decides both the static checks and whether\n# annotations are instrumented. `raw` is plain Python plus PLL\'s libraries.\n#\n# Each returns a JSON-friendly dict / list of dicts so the JS side can\n# consume the result via `proxy.toJs({ dict_converter: Object.fromEntries })`.\n#\n# Sessions\n# --------\n# Each Python file gets its own session, keyed by an opaque string the host\n# chooses (typically the document URI). Sessions hold their own globals\n# dict, so file A\'s `data = ...` doesn\'t leak into file B\'s REPL prompt.\n# `_pll_run_file` resets the addressed session\'s globals to the baseline\n# template before executing; `_pll_repl_eval` does NOT reset, so REPL\n# input keeps the names defined by the most recent Run File of the same\n# session.\n\nimport traceback as _tb_mod\nimport ast as _ast\nimport copy as _pll_copy\nimport codeop as _codeop\nimport contextlib\nimport json as _pll_json\nimport sys as _sys\nimport builtins as _builtins_mod\n\n# Must match PLL_WORK_DIR in memfsWorkspace.ts. Sibling files are mounted\n# here and it is cwd, so open("cars.csv") works. It must not sit first on\n# sys.path or a neighboring pandas.py wins over the real package.\n_PLL_WORK_DIR = "/home/pyodide/pll_workspace"\n\n# Vendored pure-Python wheels (typeguard + typing_extensions) that the\n# worker writes into MEMFS before calling `_pll_enable_type_checking`.\n# Must match PLL_VENDOR_DIR in pythonVendor.ts.\n_PLL_VENDOR_DIR = "/pll_vendor"\n\n# Set by `_pll_enable_type_checking`. While False, `_pll_instrument_types`\n# leaves code untouched, so a program still runs - just unchecked.\n_PLL_TYPEGUARD_READY = False\n_pll_typeguard_transformer = None\n_pll_type_check_error = None\n\n# Whether a bool is rejected where int / float is annotated. Set per run\n# from the language level; see `levelRejectsBoolAsNumber` in level.ts.\n# Python counts True as 1 and both mypy and typeguard follow it, which is a\n# hole at the teaching levels: a student who annotates `int` and passes\n# `True` has almost always made a mistake. `advanced` keeps Python\'s rule.\n_PLL_STRICT_NUMBERS = False\n\n# Whether annotations are instrumented at all. Set per run from the language\n# level; see `levelHasTypeChecking` in level.ts. False only at `#level raw`,\n# which exists so a file can opt out. There is no separate setting: the level\n# is the single input, so nothing can disagree with it.\n_PLL_TYPE_CHECK = False\n\n\ndef _pll_check_strict_int(value, origin_type, args, memo):\n """Replaces typeguard\'s `int` check; bools are not whole numbers here.\n\n The wording matches typeguard\'s own so the host-side explainer needs\n no special case: it sees the actual type (bool) and the expected one.\n """\n if isinstance(value, bool) or not isinstance(value, int):\n raise _pll_type_check_error("is not an instance of int")\n\n\ndef _pll_check_strict_float(value, origin_type, args, memo):\n """As `_pll_check_strict_int`, for the int-or-float numeric tower."""\n if isinstance(value, bool) or not isinstance(value, (int, float)):\n raise _pll_type_check_error("is neither float or int")\n\n\ndef _pll_strict_number_lookup(origin_type, args, extras):\n """typeguard checker lookup; consulted on every check, so the flag\n can be flipped per run without re-registering."""\n if not _PLL_STRICT_NUMBERS:\n return None\n if origin_type is int:\n return _pll_check_strict_int\n if origin_type is float:\n return _pll_check_strict_float\n return None\n\n\ndef _pll_apply_level(level):\n """Set the per-run strictness implied by the language level.\n\n The level is the only input. `raw` checks nothing; `advanced` checks\n annotations by Python\'s own rules; the two teaching levels additionally\n reject a bool where a number is annotated. Must agree with level.ts.\n """\n global _PLL_STRICT_NUMBERS, _PLL_TYPE_CHECK\n _PLL_TYPE_CHECK = level != "raw"\n _PLL_STRICT_NUMBERS = level in ("beginner", "intermediate")\n\n\ndef _pll_enable_type_checking():\n """Put the vendored wheels on sys.path and import typeguard.\n\n Returns True when runtime type checking is available. A failure here\n is deliberately not fatal: students\' code must still run, so the\n feature degrades to "no type checks" rather than breaking the editor.\n """\n global _PLL_TYPEGUARD_READY, _pll_typeguard_transformer, _pll_type_check_error\n if _PLL_TYPEGUARD_READY:\n return True\n try:\n for name in ("typing_extensions.whl", "typeguard.whl"):\n path = _PLL_VENDOR_DIR + "/" + name\n # Appended, not prepended: a real package in site-packages\n # (Pyodide ships typing_extensions too) should still win.\n if path not in _sys.path:\n _sys.path.append(path)\n from typeguard import TypeCheckError, TypeCheckMemo\n from typeguard._checkers import checker_lookup_functions\n from typeguard._config import CollectionCheckStrategy, global_config\n from typeguard._functions import check_variable_assignment\n from typeguard._transformer import TypeguardTransformer\n\n # `list[int]` should mean *every* item is an int. typeguard only\n # checks the first item by default, which silently accepts\n # [1, 2, "three"] - confusing when the annotation says otherwise.\n global_config.collection_check_strategy = CollectionCheckStrategy.ALL_ITEMS\n\n # Referenced by name in the AST that `_PllTopLevelAnnAssign`\n # injects, so they have to be visible in user globals.\n _pll_initial_globals["_pll_tg_memo"] = TypeCheckMemo\n _pll_initial_globals["_pll_tg_check_assign"] = check_variable_assignment\n # Consulted before typeguard\'s builtin lookup, and gated on\n # `_PLL_STRICT_NUMBERS` so it is a no-op at `advanced`.\n _pll_type_check_error = TypeCheckError\n if _pll_strict_number_lookup not in checker_lookup_functions:\n checker_lookup_functions.insert(0, _pll_strict_number_lookup)\n\n _pll_typeguard_transformer = TypeguardTransformer\n _PLL_TYPEGUARD_READY = True\n except BaseException:\n _PLL_TYPEGUARD_READY = False\n return _PLL_TYPEGUARD_READY\n\n\ndef _pll_is_vendor_frame(filename):\n return isinstance(filename, str) and filename.startswith(_PLL_VENDOR_DIR)\n\n\ndef _pll_format_exception(exc):\n """`format_exception`, minus frames inside the vendored type checker.\n\n A typeguard failure otherwise ends in several frames of typeguard\'s\n own checker, burying the student\'s line under machinery they did not\n write. When nothing is dropped this returns the stdlib formatting\n unchanged, so ordinary errors look exactly as they did before.\n """\n try:\n frames = _tb_mod.extract_tb(exc.__traceback__)\n kept = [f for f in frames if not _pll_is_vendor_frame(f.filename)]\n if len(kept) == len(frames):\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n parts = ["Traceback (most recent call last):\\n"]\n parts.extend(_tb_mod.StackSummary.from_list(kept).format())\n parts.extend(_tb_mod.format_exception_only(type(exc), exc))\n return "".join(parts)\n except BaseException:\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n\n\ndef _pll_protect_import_path():\n """Keep sibling .py files from shadowing installed packages.\n\n Python puts \'\' (cwd) first on sys.path. After chdir to the work dir,\n import pandas would load a mounted pandas.py. Drop the empty entry,\n append the work dir so unique sibling modules still import, and evict\n anything already loaded from that folder (a failed pandas.py import\n leaves a poisoned sys.modules entry).\n """\n while "" in _sys.path:\n _sys.path.remove("")\n while _PLL_WORK_DIR in _sys.path:\n _sys.path.remove(_PLL_WORK_DIR)\n _sys.path.append(_PLL_WORK_DIR)\n prefix = _PLL_WORK_DIR + "/"\n for name, mod in list(_sys.modules.items()):\n filename = getattr(mod, "__file__", None)\n if isinstance(filename, str) and filename.startswith(prefix):\n _sys.modules.pop(name, None)\n\n# Per-session globals dicts, keyed by session_key (e.g. document URI).\n# Created lazily; initialized from `_pll_initial_globals`.\n_pll_sessions = {}\n\n# The "template" globals used to seed each new session and to reset a\n# session at the start of every Run File. Populated by PYODIDE_INSTALL_PY\n# at the end of bootstrap so the template includes the PLL image\n# library + the auto-display helper.\n_pll_initial_globals = {"__name__": "__main__", "__builtins__": __builtins__}\n\n# Display emissions captured during the most recent `_pll_run_file` /\n# `_pll_repl_eval` call. The host drains this list after the call.\n# Each entry is a typed dict (`{"type": "stdout"|"stderr", "text": ...}`,\n# `{"type": "image", ...}`, or `{"type": "table", ...}`) so the host can\n# dispatch by kind while preserving the *exact* order in which the user\'s\n# code produced them - that way a `print()` followed by a top-level table\n# shows up as text-then-table in the interactions view, even though\n# stdout and image/table emissions take different paths inside Python.\n# Pyodide is single-threaded, so a single shared list is fine.\n_pll_displays = []\n\n# When set (by either host during a file run), this is a JS callback taking\n# one JSON string. Every display payload \u2014 each stdout/stderr write, image,\n# and table \u2014 is emitted through it as it is produced, so the interactions\n# view updates *during* the run instead of only at the end. This is what\n# lets an interactive program print a prompt before input() blocks.\n# Left as None during REPL/tests, where output is delivered in one batch.\n_pll_live_emit = None\n\n\ndef _pll_push(payload):\n """Emit a display payload live, or record it for the end of the run.\n\n Exactly one of the two: when a live hook is installed the host streams\n each payload as it happens and then discards `result["displays"]`\n (see `withLiveEmit` / the `runFile` case in workerHost.ts), so also\n accumulating them costs memory and a large FFI conversion for a list\n nobody reads. A program printing in a loop built a multi-million entry\n list that was copied and converted to JS purely to be dropped.\n """\n emit = _pll_live_emit\n if emit is None:\n _pll_displays.append(payload)\n return\n try:\n emit(_pll_json.dumps(payload))\n except Exception:\n pass\n\n\nclass _PllStream:\n """Drop-in replacement for `sys.stdout` / `sys.stderr` during a run.\n\n Each `write` pushes a typed entry onto `_pll_displays` so text\n output interleaves with image/table emissions. We also keep the\n aggregate string so `result["stdout"]` / `result["stderr"]` can\n still be inspected by smoke tests and any caller that just wants\n "what did the program print?".\n """\n\n __slots__ = ("_kind", "_chunks")\n\n def __init__(self, kind):\n self._kind = kind # "stdout" or "stderr"\n self._chunks = []\n\n def write(self, s):\n if not isinstance(s, str):\n s = str(s)\n if s:\n _pll_push({"type": self._kind, "text": s})\n self._chunks.append(s)\n return len(s)\n\n def writelines(self, lines):\n for line in lines:\n self.write(line)\n\n def flush(self):\n pass\n\n def isatty(self):\n return False\n\n def getvalue(self):\n return "".join(self._chunks)\n\n\ndef _pll_extract_display(value):\n """Try every known display protocol on `value`.\n\n Returns a typed payload (`{"type": "image"|"table", ...}`) or None if\n `value` doesn\'t know how to display itself.\n """\n if hasattr(value, "_pll_table_data"):\n try:\n data = value._pll_table_data()\n except Exception:\n return None\n if isinstance(data, dict):\n data = dict(data)\n data["type"] = "table"\n return data\n if hasattr(value, "_pll_image_data"):\n try:\n data = value._pll_image_data()\n except Exception:\n return None\n # _pll_image_data historically uses {"type": "svg", ...}\n # internally; promote to the unified outer type.\n if isinstance(data, dict):\n payload = dict(data)\n payload["type"] = "image"\n payload["format"] = data.get("type", "svg")\n return payload\n return None\n\n\ndef _pll_show_top_level(value):\n """Emit a value produced by a top-level expression statement.\n\n Mirrors the behavior of Python\'s interactive shell: `None` is suppressed,\n PLL images and tables are captured for the host to render, anything\n else is printed via `repr` so bare expressions like `1 + 2` still display.\n """\n if value is None:\n return\n # A reactor pushed its own card when it was started; printing its repr\n # as well would be noise. Duck-typed like the display protocols, so the\n # bootstrap still knows nothing about reactorLib.\n if getattr(value, "_pll_already_displayed", False):\n return\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n return\n print(repr(value))\n\n\n# Seed the template with the auto-display helper. The image library names\n# get added later by PYODIDE_INSTALL_PY.\n_pll_initial_globals["_pll_show_top_level"] = _pll_show_top_level\n\n\ndef _pll_get_session(session_key):\n """Get-or-create the globals dict for `session_key`.\n\n Newly-created sessions start as a copy of `_pll_initial_globals`\n (so all baseline names like the image primitives are present).\n """\n g = _pll_sessions.get(session_key)\n if g is None:\n g = dict(_pll_initial_globals)\n _pll_sessions[session_key] = g\n return g\n\n\ndef _pll_reset_session(session_key):\n """Reset the globals for `session_key` to the baseline template.\n\n Mutates the existing dict in place (`clear` + `update`) so any cached\n reference to it (e.g. from `_pll_show_top_level`\'s closure or from\n Pyodide\'s `globals.get(...)`) remains valid.\n """\n g = _pll_get_session(session_key)\n g.clear()\n g.update(_pll_initial_globals)\n return g\n\n\nclass _PllTopLevelAnnAssign(_ast.NodeTransformer):\n """Type-check annotated assignments outside functions.\n\n typeguard instruments function arguments, return values, and\n annotated assignments *inside* functions, but leaves\n `total: int = "oops"` at module level unchecked. Students write those,\n so we wrap the value in typeguard\'s own check to get the same wording.\n\n Function and class bodies are skipped: typeguard already covers\n functions, and a bare `name: str` in a class body is a field\n declaration (dataclasses, etc.) rather than an assignment.\n """\n\n def visit_FunctionDef(self, node):\n return node\n\n visit_AsyncFunctionDef = visit_FunctionDef\n\n def visit_ClassDef(self, node):\n return node\n\n def visit_AnnAssign(self, node):\n if node.value is None or not isinstance(node.target, _ast.Name):\n return node\n node.value = self._checked(node.target.id, node.value, node.annotation)\n return node\n\n def _checked(self, name, value, annotation):\n """`value` -> `_pll_tg_check_assign(value, [(name, ann)], memo)`."""\n memo = _ast.Call(\n func=_ast.Name(id="_pll_tg_memo", ctx=_ast.Load()),\n args=[\n _ast.Call(func=_ast.Name(id="globals", ctx=_ast.Load()), args=[], keywords=[]),\n _ast.Call(func=_ast.Name(id="locals", ctx=_ast.Load()), args=[], keywords=[]),\n ],\n keywords=[],\n )\n target = _ast.Tuple(\n elts=[_ast.Constant(value=name), _pll_copy.deepcopy(annotation)],\n ctx=_ast.Load(),\n )\n call = _ast.Call(\n func=_ast.Name(id="_pll_tg_check_assign", ctx=_ast.Load()),\n args=[value, _ast.List(elts=[target], ctx=_ast.Load()), memo],\n keywords=[],\n )\n _ast.copy_location(call, value)\n _ast.fix_missing_locations(call)\n return call\n\n\ndef _pll_parse_and_instrument(code, filename):\n """Parse `code`, adding runtime type checks when they are available.\n\n Whether to check at all comes from `_PLL_TYPE_CHECK`, which\n `_pll_apply_level` sets from the level before this is called.\n\n Instrumentation is attempted on a second parse and validated by\n compiling it, so anything typeguard cannot handle falls back to the\n plain tree rather than failing the run.\n """\n tree = _ast.parse(code, filename=filename, mode="exec")\n if not _PLL_TYPE_CHECK or not _PLL_TYPEGUARD_READY:\n return tree\n try:\n instrumented = _ast.parse(code, filename=filename, mode="exec")\n _pll_typeguard_transformer().visit(instrumented)\n _PllTopLevelAnnAssign().visit(instrumented)\n _ast.fix_missing_locations(instrumented)\n compile(instrumented, filename, "exec")\n return instrumented\n except BaseException:\n return tree\n\n\nclass _PllTopLevelExprWrapper(_ast.NodeTransformer):\n """Wrap module-level expression statements so they auto-display.\n\n Skips the conventional module docstring (a string literal as the first\n statement) and bare `None` / `...` constants which are usually noise.\n """\n\n def visit_Module(self, node):\n new_body = []\n for i, stmt in enumerate(node.body):\n if isinstance(stmt, _ast.Expr) and not _pll_should_skip_expr(stmt, i):\n call = _ast.Call(\n func=_ast.Name(id="_pll_show_top_level", ctx=_ast.Load()),\n args=[stmt.value],\n keywords=[],\n )\n wrapped = _ast.Expr(value=call)\n _ast.copy_location(wrapped, stmt)\n _ast.fix_missing_locations(wrapped)\n new_body.append(wrapped)\n else:\n new_body.append(stmt)\n node.body = new_body\n return node\n\n\ndef _pll_should_skip_expr(stmt, index):\n value = stmt.value\n if isinstance(value, _ast.Constant):\n if index == 0 and isinstance(value.value, str):\n return True # module docstring\n if value.value is None or value.value is Ellipsis:\n return True\n return False\n\n\n# -----------------------------------------------------------------------------\n# REPL syntax check (codeop.compile_command in \'single\' mode)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_check(source):\n """Return whether \'source\' is a complete REPL input.\n\n Mirrors what CPython\'s interactive shell does: uses codeop.compile_command\n in \'single\' mode, which returns None for incomplete input (e.g. open\n parens, unfinished block) and raises SyntaxError for actually-broken code.\n """\n try:\n result = _codeop.compile_command(source, "<repl>", "single")\n except (SyntaxError, OverflowError, ValueError) as e:\n return {\n "status": "invalid",\n "error_type": type(e).__name__,\n "message": str(e),\n "lineno": getattr(e, "lineno", None) or 0,\n "offset": (e.offset - 1) if isinstance(e, SyntaxError) and e.offset else 0,\n }\n if result is None:\n return {"status": "incomplete"}\n return {"status": "complete"}\n\n\n# -----------------------------------------------------------------------------\n# Run a whole file (with output capture and traceback extraction)\n# -----------------------------------------------------------------------------\n\ndef _pll_extract_loc(tb_str, fallback_filename):\n line_no = None\n col = None\n for raw in reversed(tb_str.splitlines()):\n line = raw.strip()\n if line.startswith(\'File "\') and ", line " in line:\n try:\n rest = line.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n line_no = None\n break\n if line_no is None and fallback_filename:\n for raw in tb_str.splitlines():\n if fallback_filename in raw and ", line " in raw:\n try:\n rest = raw.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n pass\n break\n return line_no, col\n\n\ndef _pll_run_file(code, filename, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n # Each Run File starts with a clean slate for this session: discard any\n # names defined by a previous Run File of the same session or by REPL\n # exploration since then.\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_reset_session(session_key)\n _pll_displays.clear()\n try:\n tree = _pll_parse_and_instrument(code, filename)\n _PllTopLevelExprWrapper().visit(tree)\n _ast.fix_missing_locations(tree)\n compiled = compile(tree, filename, "exec")\n except SyntaxError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = tb_text\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, user_globals)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# Tests (pytest, same-file test_* / Test* collection)\n# -----------------------------------------------------------------------------\n\ndef _pll_has_tests(code):\n """Return True if `code` looks like it contains pytest tests.\n\n Mirrors pytest\'s default collection: module-level `test_*` functions\n and `test_*` methods on `Test*` classes. Used so we can skip loading\n pytest for files that have no tests.\n """\n try:\n tree = _ast.parse(code)\n except (SyntaxError, ValueError):\n return False\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n return True\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n return True\n return False\n\n\n_PLL_TRACE_SKIP = (\n _PLL_VENDOR_DIR,\n "_pytest",\n "pluggy",\n "site-packages",\n "/lib/python",\n "get_terminal_writer",\n "pyio.py",\n "pathlib",\n)\n\n\ndef _pll_is_internal_frame(line):\n return any(tok in line for tok in _PLL_TRACE_SKIP)\n\n\ndef _pll_fix_ast_ranges(tree):\n """Make rewritten asserts compile on Python 3.12+.\n\n `rewrite_asserts` can leave `end_lineno` < `lineno` on injected nodes.\n CPython then raises `ValueError: AST node line range (...) is not valid`.\n """\n for child in _ast.walk(tree):\n lineno = getattr(child, "lineno", None)\n end_lineno = getattr(child, "end_lineno", None)\n if lineno is not None and end_lineno is not None and end_lineno < lineno:\n child.end_lineno = lineno\n col = getattr(child, "col_offset", None)\n end_col = getattr(child, "end_col_offset", None)\n if (\n col is not None\n and end_col is not None\n and lineno is not None\n and end_lineno is not None\n and lineno == end_lineno\n and end_col < col\n ):\n child.end_col_offset = col\n\n\ndef _pll_test_locations(tree):\n """Map collected test names to 1-based source lines."""\n locs = {}\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n locs[node.name] = node.lineno\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n locs[node.name + "::" + item.name] = item.lineno\n return locs\n\n\ndef _pll_friendly_assert_message(exc, tb_text):\n """Short explanation of a failed assert, with pytest internals stripped."""\n msg = str(exc).strip()\n lines = []\n for raw in msg.split("\\n"):\n stripped = raw.strip()\n if not stripped:\n continue\n if _pll_is_internal_frame(stripped):\n continue\n if stripped.startswith("+"):\n stripped = stripped.lstrip("+ ").strip()\n lines.append(stripped)\n if lines:\n return "\\n".join(lines)\n for raw in reversed((tb_text or "").splitlines()):\n stripped = raw.strip()\n if stripped.startswith("assert "):\n return stripped\n return "This test failed."\n\n\ndef _pll_is_async(fn):\n try:\n import inspect\n return inspect.iscoroutinefunction(fn)\n except Exception:\n return False\n\n\ndef _pll_call_test(fn):\n """Run one test function. Returns (outcome, message, stdout)."""\n import io\n\n buf = io.StringIO()\n try:\n with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):\n if _pll_is_async(fn):\n return (\n "error",\n "async tests are not supported.",\n buf.getvalue().strip() or None,\n )\n fn()\n return ("passed", None, buf.getvalue().strip() or None)\n except AssertionError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n return (\n "failed",\n _pll_friendly_assert_message(e, tb_text),\n buf.getvalue().strip() or None,\n )\n except BaseException as e:\n name = type(e).__name__\n if name == "Skipped":\n return ("skipped", str(e).strip() or None, buf.getvalue().strip() or None)\n return (\n "error",\n name + ": " + (str(e) or "this test raised an exception."),\n buf.getvalue().strip() or None,\n )\n\n\ndef _pll_iter_tests(ns):\n """Yield (name, callable) for pytest-style tests in namespace `ns`."""\n items = list(ns.items())\n for name, obj in items:\n if name.startswith("test_") and callable(obj) and not isinstance(obj, type):\n yield name, obj\n for name, obj in items:\n if not (isinstance(obj, type) and name.startswith("Test")):\n continue\n # pytest skips classes with a custom constructor.\n try:\n obj()\n except TypeError:\n continue\n for meth_name in dir(obj):\n if not meth_name.startswith("test_"):\n continue\n attr = getattr(obj, meth_name, None)\n if not callable(attr):\n continue\n\n def _bound(cls=obj, method=meth_name):\n return getattr(cls(), method)()\n\n yield name + "::" + meth_name, _bound\n\n\ndef _pll_run_tests(code, filename, level="raw"):\n """Run same-file tests (`test_*` / `Test*`) in an isolated namespace.\n\n Uses pytest only to rewrite assertions so failures show `assert 4 == 5`\n instead of an empty AssertionError. Does **not** call `pytest.main()`,\n which is not safe to invoke repeatedly in one Pyodide interpreter.\n """\n display_name = filename.rsplit("/", 1)[-1].rsplit("\\\\", 1)[-1] or "user_script.py"\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "internal_error": False,\n "passed": 0,\n "failed": 0,\n "skipped": 0,\n "errors": 0,\n "tests": [],\n "stdout": "",\n "stderr": "",\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n\n try:\n tree = _pll_parse_and_instrument(code, display_name)\n except SyntaxError as e:\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n locs = _pll_test_locations(tree)\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n rewrite_asserts(tree, code.encode("utf-8"), module_path=display_name)\n # Do not call ast.fix_missing_locations here: it copies parent\n # positions onto pytest\'s injected nodes and yields ranges that\n # Python 3.12+ rejects (`end_lineno` < `lineno`).\n _pll_fix_ast_ranges(tree)\n compiled = compile(tree, display_name, "exec")\n except Exception:\n # Assert rewriting failed; keep the type instrumentation.\n compiled = compile(tree, display_name, "exec")\n\n ns = dict(_pll_initial_globals)\n ns["__name__"] = "__pll_test__"\n ns["__file__"] = display_name\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, ns)\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, display_name)\n result["line_number"] = line_no\n result["column"] = col\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n rows = []\n passed = failed = errors = skipped = 0\n for name, fn in _pll_iter_tests(ns):\n outcome, message, cap = _pll_call_test(fn)\n if outcome == "passed":\n passed += 1\n elif outcome == "failed":\n failed += 1\n elif outcome == "skipped":\n skipped += 1\n else:\n errors += 1\n rows.append({\n "name": name,\n "outcome": outcome,\n "line_number": locs.get(name),\n "message": message,\n "stdout": cap,\n })\n\n result["passed"] = passed\n result["failed"] = failed\n result["skipped"] = skipped\n result["errors"] = errors\n result["tests"] = rows\n result["ok"] = failed == 0 and errors == 0\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# REPL-style eval (statements + last-expression value)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_eval(code, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_get_session(session_key)\n filename = "<repl>"\n try:\n tree = _pll_parse_and_instrument(code, filename)\n except SyntaxError as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n last_expr = None\n if tree.body and isinstance(tree.body[-1], _ast.Expr):\n last_expr = tree.body[-1]\n tree.body = tree.body[:-1]\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n if tree.body:\n compiled_stmts = compile(tree, filename, "exec")\n exec(compiled_stmts, user_globals)\n if last_expr is not None:\n expr_module = _ast.Expression(body=last_expr.value)\n compiled_expr = compile(expr_module, filename, "eval")\n value = eval(compiled_expr, user_globals)\n if value is not None:\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n else:\n result["result_repr"] = repr(value)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# =============================================================================\n# Static analysis (beginner / intermediate level checks)\n# =============================================================================\n#\n# Per-level rules:\n#\n# beginner:\n# 1. Shadowing: a binding whose name appears in any enclosing\n# scope, is the name of a Python built-in, or is\n# provided by a PLL library (image / table /\n# reactor - the names every session starts with).\n# 2. Reassignment: a name bound more than once within the *same*\n# scope. Suppressed for names already flagged as\n# shadowing in that scope (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# intermediate:\n# 1. Shadowing: same as beginner.\n# 2. Reassignment: only flagged at module scope. Function/lambda/\n# class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator\n# patterns (e.g. `total = 0; for x in xs: total += x`\n# inside `def`).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# advanced:\n# No checks. Full Python.\n#\n# A "scope" is one of: module, function (incl. async), lambda, class,\n# comprehension/generator. We model these explicitly because Python 3\n# comprehensions have their own scope.\n\n\n_PLL_SCOPE_FUNC = (_ast.FunctionDef, _ast.AsyncFunctionDef)\n_PLL_SCOPE_COMP = (_ast.ListComp, _ast.SetComp, _ast.DictComp, _ast.GeneratorExp)\n\n# Attributes `dir()` reports for the `builtins` *module object* itself. They\n# are not built-in functions, so reporting them as shadowed would be a false\n# positive with misleading wording ("`__name__` is the name of a Python\n# built-in"). Everything else `dir()` returns is a real builtin and is worth\n# flagging, including the dunder ones like `__import__`.\n_PLL_BUILTINS_MODULE_META = frozenset(\n ("__doc__", "__loader__", "__name__", "__package__", "__spec__")\n)\n\n\nclass _PllScope:\n __slots__ = ("node", "kind", "parent", "bindings")\n\n def __init__(self, node, kind, parent):\n self.node = node\n self.kind = kind # "module" | "function" | "lambda" | "class" | "comprehension"\n self.parent = parent # _PllScope | None\n # name -> [(lineno, col, kind, module), ...]. `module` is set only\n # for import bindings: the module the name was imported from.\n self.bindings = {}\n\n\ndef _pll_arg_names(args):\n """All argument names (with positions) on an ast.arguments node."""\n out = []\n posonly = list(getattr(args, "posonlyargs", []) or [])\n for a in posonly + list(args.args) + list(args.kwonlyargs):\n out.append((a.arg, a.lineno, a.col_offset))\n if args.vararg:\n out.append((args.vararg.arg, args.vararg.lineno, args.vararg.col_offset))\n if args.kwarg:\n out.append((args.kwarg.arg, args.kwarg.lineno, args.kwarg.col_offset))\n return out\n\n\nclass _PllScopeBuilder:\n """Walk a module AST and build a list of scopes with their bindings.\n\n A binding is collected in the *enclosing* scope of the syntactic node\n (e.g. a `def f` adds `f` to the surrounding scope and creates a new\n function scope for its body). Comprehensions, lambdas, and classes\n each open their own scope.\n """\n\n def __init__(self):\n self.scopes = []\n\n def build(self, tree):\n module = _PllScope(tree, "module", None)\n self.scopes.append(module)\n for stmt in tree.body:\n self._walk(stmt, module)\n return module\n\n def _add(self, scope, name, lineno, col, kind, module=None):\n scope.bindings.setdefault(name, []).append((lineno, col, kind, module))\n\n def _add_target(self, target, scope, kind):\n if isinstance(target, _ast.Name):\n self._add(scope, target.id, target.lineno, target.col_offset, kind)\n elif isinstance(target, (_ast.Tuple, _ast.List)):\n for elt in target.elts:\n self._add_target(elt, scope, kind)\n elif isinstance(target, _ast.Starred):\n self._add_target(target.value, scope, kind)\n else:\n # Subscript/Attribute targets are not name bindings.\n self._walk(target, scope)\n\n def _walk(self, node, scope):\n # --- Binding-creating statements ---------------------------------\n if isinstance(node, _ast.Assign):\n for t in node.targets:\n self._add_target(t, scope, "assign")\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AnnAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "annassign")\n else:\n self._walk(node.target, scope)\n if node.annotation is not None:\n self._walk(node.annotation, scope)\n if node.value is not None:\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AugAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "augassign")\n else:\n self._walk(node.target, scope)\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.For, _ast.AsyncFor)):\n self._add_target(node.target, scope, "for")\n self._walk(node.iter, scope)\n for s in node.body:\n self._walk(s, scope)\n for s in node.orelse:\n self._walk(s, scope)\n return\n if isinstance(node, (_ast.With, _ast.AsyncWith)):\n for item in node.items:\n self._walk(item.context_expr, scope)\n if item.optional_vars is not None:\n self._add_target(item.optional_vars, scope, "with")\n for s in node.body:\n self._walk(s, scope)\n return\n if isinstance(node, _ast.Import):\n for alias in node.names:\n name = alias.asname or alias.name.split(".", 1)[0]\n self._add(scope, name, node.lineno, node.col_offset, "import", alias.name)\n return\n if isinstance(node, _ast.ImportFrom):\n for alias in node.names:\n name = alias.asname or alias.name\n self._add(scope, name, node.lineno, node.col_offset, "importfrom", node.module)\n return\n if isinstance(node, _ast.NamedExpr):\n # Walrus :=\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "walrus")\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n # These don\'t create bindings.\n return\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n self._add(scope, node.name, node.lineno, node.col_offset, "functiondef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "function", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _ast.Lambda):\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "lambda", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n self._walk(node.body, inner)\n return\n if isinstance(node, _ast.ClassDef):\n self._add(scope, node.name, node.lineno, node.col_offset, "classdef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for b in node.bases:\n self._walk(b, scope)\n for kw in node.keywords:\n self._walk(kw.value, scope)\n inner = _PllScope(node, "class", scope)\n self.scopes.append(inner)\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _PLL_SCOPE_COMP):\n inner = _PllScope(node, "comprehension", scope)\n self.scopes.append(inner)\n # Per Python semantics: outermost iter is evaluated in the OUTER\n # scope, everything else inside the comp scope.\n for i, gen in enumerate(node.generators):\n if i == 0:\n self._walk(gen.iter, scope)\n else:\n self._walk(gen.iter, inner)\n self._add_target(gen.target, inner, "comprehension")\n for cond in gen.ifs:\n self._walk(cond, inner)\n if isinstance(node, _ast.DictComp):\n self._walk(node.key, inner)\n self._walk(node.value, inner)\n else:\n self._walk(node.elt, inner)\n return\n\n # --- Default: descend in the same scope --------------------------\n for child in _ast.iter_child_nodes(node):\n self._walk(child, scope)\n\n\nclass _PllKeywordVisitor(_ast.NodeVisitor):\n """Collect every `global` / `nonlocal` statement in a tree.\n\n Each entry is `(keyword, lineno, col_offset, names)` where `keyword`\n is the literal string "global" or "nonlocal" and `names` is the list\n of identifiers the statement applies to.\n """\n\n def __init__(self):\n self.found = []\n\n def visit_Global(self, node):\n self.found.append(("global", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n def visit_Nonlocal(self, node):\n self.found.append(("nonlocal", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n\ndef _pll_session_bound_names(session_key):\n """User-defined names already bound in a session (for REPL checks).\n\n Baseline names from `_pll_initial_globals` (image primitives, etc.) are\n skipped unless the user rebound them, so prompt analysis matches file\n analysis for the same snippet.\n """\n g = _pll_sessions.get(session_key)\n if not g:\n return []\n names = []\n for n in g:\n if n.startswith("_") or n in ("__builtins__", "__name__", "__doc__"):\n continue\n if n in _pll_initial_globals and g[n] is _pll_initial_globals[n]:\n continue\n names.append(n)\n return names\n\n\ndef _pll_predefined_library_names():\n """Public names every session starts with, mapped to their library.\n\n PYODIDE_INSTALL_PY seeds `_pll_initial_globals` with the image / table\n / reactor exports, so those names (`circle`, `rectangle`, `table`,\n `animate`, ...) are bound before the student writes anything. A student\n definition that reuses one is shadowing, exactly like a built-in - just\n with wording that says where the name comes from. The label is derived\n from the registered `pll` package when it is loaded, and falls back to\n the generic "library" otherwise (e.g. bootstrap-only test harnesses).\n """\n public = {n for n in _pll_initial_globals if not n.startswith("_")}\n labels = {}\n pll = _sys.modules.get("pll")\n if pll is not None:\n for lib in ("image", "table", "reactor"):\n mod = getattr(pll, lib, None)\n if mod is None:\n continue\n for n in public:\n if hasattr(mod, n):\n labels[n] = lib\n return {n: labels.get(n, "library") for n in public}\n\n\ndef _pll_is_pll_import_loc(loc):\n """True when a binding loc tuple imports from a `pll` module.\n\n `from pll.image import circle` re-binds the library\'s own value, so it\n shadows nothing and must not be flagged.\n """\n kind, module = loc[2], loc[3]\n return (\n kind in ("import", "importfrom")\n and isinstance(module, str)\n and (module == "pll" or module.startswith("pll."))\n )\n\n\ndef _pll_static_analyze(code, level, filename, session_key=None):\n """Run static checks for `level` over `code` and return findings.\n\n Returns a list of dicts. Each dict has at minimum:\n id, error_type, message, line_number, column, name_token, scope_kind.\n\n If `session_key` is set, names already bound in that session are treated\n as existing module-level bindings. That way a `#level beginner` prompt cannot\n reassign a name the file (or an earlier prompt line) already defined.\n """\n if level not in ("beginner", "intermediate"):\n return []\n try:\n tree = _ast.parse(code, filename=filename)\n except SyntaxError:\n # Let the runtime path surface SyntaxErrors with their normal flow.\n return []\n\n findings = []\n builder = _PllScopeBuilder()\n builder.build(tree)\n if session_key:\n module = builder.scopes[0]\n for name in _pll_session_bound_names(session_key):\n locs = module.bindings.setdefault(name, [])\n locs.insert(0, (0, 0, "preexisting", None))\n builtins_set = set(dir(_builtins_mod)) - _PLL_BUILTINS_MODULE_META\n library_names = _pll_predefined_library_names()\n\n # Whether we flag reassignment in `scope` at this level. At beginner,\n # we flag everywhere; at intermediate, only at module scope so that\n # function-local accumulator patterns (`total = 0; for x in xs:\n # total += x`) work.\n def reassignment_active(scope_kind):\n if level == "beginner":\n return True\n if level == "intermediate":\n return scope_kind == "module"\n return False\n\n for scope in builder.scopes:\n # Names visible from any enclosing scope, paired with the *nearest*\n # outer binding\'s location and scope kind. We walk parents inner-to-\n # outer and refuse to overwrite, so the closest enclosing binding wins\n # (which is the one Python\'s lookup rules would resolve to).\n enclosing = {} # name -> (lineno, col, scope_kind)\n cur = scope.parent\n while cur is not None:\n for outer_name, outer_locs in cur.bindings.items():\n if outer_name in enclosing:\n continue\n outer_first = outer_locs[0]\n enclosing[outer_name] = (outer_first[0], outer_first[1], cur.kind)\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n first_loc = locs[0]\n # A binding imported from a `pll` module re-binds the library\'s\n # own value and shadows nothing; a "preexisting" marker records\n # a name the session already bound (REPL analysis) rather than\n # one this code defines. Report at the first binding that\n # actually (re)defines the name; when there is none, this name\n # has nothing to report here.\n defining_loc = None\n for loc in locs:\n if not _pll_is_pll_import_loc(loc) and loc[2] != "preexisting":\n defining_loc = loc\n break\n report_loc = defining_loc if defining_loc is not None else first_loc\n if name in enclosing:\n shadowed_in_scope.add(name)\n outer = enclosing[name]\n findings.append({\n "id": "shadowing",\n "error_type": "Shadowing",\n "message": "`%s` is already defined in an outer scope" % name,\n "line_number": report_loc[0],\n "column": report_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "outer_line_number": outer[0],\n "outer_column": outer[1],\n "outer_scope_kind": outer[2],\n })\n elif name in builtins_set and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append({\n "id": "shadowing-builtin",\n "error_type": "Shadowing",\n "message": "`%s` is the name of a Python built-in" % name,\n "line_number": defining_loc[0],\n "column": defining_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n })\n elif name in library_names and defining_loc is not None:\n shadowed_in_scope.add(name)\n lib = library_names[name]\n findings.append({\n "id": "shadowing-library",\n "error_type": "Shadowing",\n "message": "`%s` is already defined by the %s library" % (name, lib),\n "line_number": defining_loc[0],\n "column": defining_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "library": lib,\n })\n\n # ---- Then reassignment (skip names already shadow-flagged) ----\n if reassignment_active(scope.kind):\n for name, locs in scope.bindings.items():\n if name in shadowed_in_scope:\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n findings.append({\n "id": "reassignment",\n "error_type": "Reassignment",\n "message": "`%s` is assigned more than once in this scope" % name,\n "line_number": second_loc[0],\n "column": second_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "first_line_number": first_loc[0],\n "first_column": first_loc[1],\n })\n\n # ---- `global` / `nonlocal` keyword check ----\n # Both are disallowed at beginner and intermediate. We emit one finding\n # per declaration (not per name) so a `global x, y` produces a single\n # diagnostic on that line.\n kw_visitor = _PllKeywordVisitor()\n kw_visitor.visit(tree)\n for keyword, lineno, col, names in kw_visitor.found:\n primary = names[0] if names else ""\n findings.append({\n "id": "disallowed-keyword",\n "error_type": "DisallowedKeyword",\n "message": "`%s` is not allowed at the %s level" % (keyword, level),\n "line_number": lineno,\n "column": col,\n "name_token": primary,\n "scope_kind": "function",\n "keyword": keyword,\n "names": list(names),\n })\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
|
|
165
165
|
|
|
166
166
|
// src/common/imageLib.py
|
|
167
167
|
var imageLib_default = '# PLL image library.\n#\n# A small, immutable, HtDP-style image library inspired by Racket\'s\n# `2htdp/image` and Pyret\'s `image-lib`. Every primitive and combinator\n# returns a new `Image`; nothing mutates. Each `Image` knows how to render\n# itself as an SVG fragment positioned in a parent box.\n#\n# This module is loaded into Pyodide alongside `pyodideBootstrap.py`. The\n# bootstrap then injects the public names directly into the user\'s module\n# globals so a beginner can write `circle(50, "solid", "red")` with no\n# import boilerplate (matching the HtDP/Pyret experience).\n\nimport math as _math\n\n\n# -----------------------------------------------------------------------------\n# Color handling\n# -----------------------------------------------------------------------------\n\ndef _pll_color_to_css(color):\n """Convert a PLL color value to an SVG/CSS color string.\n\n Accepts:\n - a string CSS name or hex ("red", "#ff0000")\n - an (r, g, b) tuple/list of integers 0..255\n - an (r, g, b, a) tuple/list, a in 0..1 or 0..255\n """\n if isinstance(color, str):\n return color\n if isinstance(color, (tuple, list)):\n if len(color) == 3:\n r, g, b = color\n return "rgb(%d, %d, %d)" % (int(r), int(g), int(b))\n if len(color) == 4:\n r, g, b, a = color\n if a > 1:\n a = a / 255.0\n return "rgba(%d, %d, %d, %g)" % (int(r), int(g), int(b), a)\n return str(color)\n\n\ndef _pll_xml_escape(text):\n return (\n text.replace("&", "&")\n .replace("<", "<")\n .replace(">", ">")\n .replace(\'"\', """)\n .replace("\'", "'")\n )\n\n\n# -----------------------------------------------------------------------------\n# Image base class\n# -----------------------------------------------------------------------------\n#\n# Each Image has:\n# width / height -> bounding box in pixels\n# _render_body(x, y) -> SVG fragment positioned at (x, y) in\n# its parent\'s coordinate system\n# to_svg() -> standalone <svg> document\n# _pll_image_data() -> dict the host uses to display the image\n#\n\nclass Image:\n """Base class for all PLL images. Don\'t instantiate directly."""\n\n @property\n def width(self):\n raise NotImplementedError\n\n @property\n def height(self):\n raise NotImplementedError\n\n def _render_body(self, x, y):\n raise NotImplementedError\n\n def to_svg(self):\n body = self._render_body(0, 0)\n # Use ceil to give a tiny bit of room so antialiased edges aren\'t clipped.\n w = max(1, int(_math.ceil(self.width)))\n h = max(1, int(_math.ceil(self.height)))\n return (\n \'<svg xmlns="http://www.w3.org/2000/svg" \'\n \'width="%d" height="%d" \'\n \'viewBox="0 0 %d %d" \'\n \'shape-rendering="geometricPrecision">%s</svg>\'\n ) % (w, h, w, h, body)\n\n def _pll_image_data(self):\n return {\n "type": "svg",\n "width": int(_math.ceil(self.width)),\n "height": int(_math.ceil(self.height)),\n "data": self.to_svg(),\n }\n\n def __repr__(self):\n return "<Image %dx%d>" % (\n int(_math.ceil(self.width)),\n int(_math.ceil(self.height)),\n )\n\n\n# -----------------------------------------------------------------------------\n# Primitive shapes\n# -----------------------------------------------------------------------------\n\ndef _pll_paint_attrs(mode, color):\n """Return SVG paint attributes for a `mode` ("solid" / "outline") shape."""\n css = _pll_color_to_css(color)\n if mode == "outline":\n return \'fill="none" stroke="%s" stroke-width="2"\' % css\n # default: solid\n return \'fill="%s"\' % css\n\n\nclass _Circle(Image):\n def __init__(self, radius, mode, color):\n self._radius = float(radius)\n self._mode = mode\n self._color = color\n\n @property\n def width(self):\n return self._radius * 2\n\n @property\n def height(self):\n return self._radius * 2\n\n def _render_body(self, x, y):\n cx = x + self._radius\n cy = y + self._radius\n return \'<circle cx="%g" cy="%g" r="%g" %s />\' % (\n cx, cy, self._radius, _pll_paint_attrs(self._mode, self._color),\n )\n\n\nclass _Rectangle(Image):\n def __init__(self, width, height, mode, color):\n self._w = float(width)\n self._h = float(height)\n self._mode = mode\n self._color = color\n\n @property\n def width(self):\n return self._w\n\n @property\n def height(self):\n return self._h\n\n def _render_body(self, x, y):\n return \'<rect x="%g" y="%g" width="%g" height="%g" %s />\' % (\n x, y, self._w, self._h,\n _pll_paint_attrs(self._mode, self._color),\n )\n\n\nclass _Ellipse(Image):\n def __init__(self, width, height, mode, color):\n self._w = float(width)\n self._h = float(height)\n self._mode = mode\n self._color = color\n\n @property\n def width(self):\n return self._w\n\n @property\n def height(self):\n return self._h\n\n def _render_body(self, x, y):\n rx = self._w / 2.0\n ry = self._h / 2.0\n return \'<ellipse cx="%g" cy="%g" rx="%g" ry="%g" %s />\' % (\n x + rx, y + ry, rx, ry,\n _pll_paint_attrs(self._mode, self._color),\n )\n\n\nclass _Polygon(Image):\n """A polygon defined by points relative to its top-left bounding box."""\n\n def __init__(self, points, mode, color):\n # points: list of (px, py) within the local 0..w / 0..h box\n if not points:\n raise ValueError("polygon needs at least one point")\n xs = [p[0] for p in points]\n ys = [p[1] for p in points]\n self._w = max(xs) - min(xs)\n self._h = max(ys) - min(ys)\n self._origin_x = min(xs)\n self._origin_y = min(ys)\n self._points = points\n self._mode = mode\n self._color = color\n\n @property\n def width(self):\n return self._w\n\n @property\n def height(self):\n return self._h\n\n def _render_body(self, x, y):\n coords = " ".join(\n "%g,%g" % (x + (px - self._origin_x), y + (py - self._origin_y))\n for px, py in self._points\n )\n return \'<polygon points="%s" %s />\' % (\n coords, _pll_paint_attrs(self._mode, self._color),\n )\n\n\nclass _Line(Image):\n def __init__(self, dx, dy, color):\n self._dx = float(dx)\n self._dy = float(dy)\n self._color = color\n\n @property\n def width(self):\n return abs(self._dx) if abs(self._dx) > 1 else 1\n\n @property\n def height(self):\n return abs(self._dy) if abs(self._dy) > 1 else 1\n\n def _render_body(self, x, y):\n x1 = x + (0 if self._dx >= 0 else -self._dx)\n y1 = y + (0 if self._dy >= 0 else -self._dy)\n x2 = x1 + self._dx\n y2 = y1 + self._dy\n return \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="%s" stroke-width="2" stroke-linecap="round" />\' % (\n x1, y1, x2, y2, _pll_color_to_css(self._color),\n )\n\n\nclass _Text(Image):\n """SVG text. Width is estimated; SVG handles actual glyph layout."""\n\n def __init__(self, text, size, color):\n self._text = str(text)\n self._size = float(size)\n self._color = color\n\n @property\n def width(self):\n # crude estimate, ~0.6em per char on average\n return max(1, len(self._text)) * self._size * 0.6\n\n @property\n def height(self):\n return self._size * 1.2\n\n def _render_body(self, x, y):\n # Position baseline so glyphs sit inside the bounding box.\n baseline = y + self._size\n return (\n \'<text x="%g" y="%g" font-family="sans-serif" font-size="%g" fill="%s" \'\n \'text-rendering="optimizeLegibility">%s</text>\'\n ) % (\n x, baseline, self._size,\n _pll_color_to_css(self._color),\n _pll_xml_escape(self._text),\n )\n\n\n# -----------------------------------------------------------------------------\n# Combinators\n# -----------------------------------------------------------------------------\n\n# -----------------------------------------------------------------------------\n# Alignment (HtDP\'s x-place / y-place)\n# -----------------------------------------------------------------------------\n\n_PLL_X_PLACES = ("left", "center", "middle", "right")\n_PLL_Y_PLACES = ("top", "center", "middle", "bottom")\n\n\ndef _pll_check_place(place, allowed, who):\n if place not in allowed:\n raise ValueError(\n "%s: expected one of %s, got %r"\n % (who, ", ".join(repr(p) for p in allowed), place)\n )\n return place\n\n\ndef _pll_offset(place, outer, inner):\n """Where a child of size `inner` sits inside a box of size `outer`."""\n if place in ("left", "top"):\n return 0.0\n if place in ("right", "bottom"):\n return outer - inner\n return (outer - inner) / 2.0\n\n\n_pll_clip_seq = 0\n\n\ndef _pll_next_clip_id():\n """Unique per element: two crops in one picture must not share an id."""\n global _pll_clip_seq\n _pll_clip_seq += 1\n return "pllclip%d" % _pll_clip_seq\n\n\ndef _pll_clipped(body, x, y, width, height):\n """`body`, with everything outside the given rectangle clipped away."""\n cid = _pll_next_clip_id()\n return (\n \'<defs><clipPath id="%s">\'\n \'<rect x="%g" y="%g" width="%g" height="%g"/>\'\n "</clipPath></defs>"\n \'<g clip-path="url(#%s)">%s</g>\'\n ) % (cid, x, y, width, height, cid, body)\n\n\nclass _Beside(Image):\n """Children left to right, aligned vertically by `y_place`."""\n\n def __init__(self, children, y_place="center"):\n self._children = list(children)\n self._y_place = y_place\n\n @property\n def width(self):\n return sum(c.width for c in self._children)\n\n @property\n def height(self):\n return max((c.height for c in self._children), default=0)\n\n def _render_body(self, x, y):\n out = []\n cx = x\n h = self.height\n for child in self._children:\n cy = y + _pll_offset(self._y_place, h, child.height)\n out.append(child._render_body(cx, cy))\n cx += child.width\n return "".join(out)\n\n\nclass _Above(Image):\n """Children top to bottom, aligned horizontally by `x_place`."""\n\n def __init__(self, children, x_place="center"):\n self._children = list(children)\n self._x_place = x_place\n\n @property\n def width(self):\n return max((c.width for c in self._children), default=0)\n\n @property\n def height(self):\n return sum(c.height for c in self._children)\n\n def _render_body(self, x, y):\n out = []\n cy = y\n w = self.width\n for child in self._children:\n cx = x + _pll_offset(self._x_place, w, child.width)\n out.append(child._render_body(cx, cy))\n cy += child.height\n return "".join(out)\n\n\nclass _Overlay(Image):\n """First arg is on top. Children aligned by (`x_place`, `y_place`)."""\n\n def __init__(self, children, x_place="center", y_place="center"):\n if not children:\n raise ValueError("overlay needs at least one image")\n self._children = list(children)\n self._x_place = x_place\n self._y_place = y_place\n\n @property\n def width(self):\n return max(c.width for c in self._children)\n\n @property\n def height(self):\n return max(c.height for c in self._children)\n\n def _render_body(self, x, y):\n # SVG draws in document order, so render bottom-up: last arg first.\n out = []\n w = self.width\n h = self.height\n for child in reversed(self._children):\n cx = x + _pll_offset(self._x_place, w, child.width)\n cy = y + _pll_offset(self._y_place, h, child.height)\n out.append(child._render_body(cx, cy))\n return "".join(out)\n\n\nclass _LayeredXY(Image):\n """`first` at the origin, `second` offset by (dx, dy).\n\n Negative offsets move `second` left / up, which grows the bounding box\n in that direction - so the composite\'s own origin shifts. The class\n absorbs that itself: it reports the union size and moves *both* children\n right / down by however far the box grew, so the protocol\'s "draw with\n your top-left at (x, y)" contract still holds for whoever renders it.\n Nothing else in the library needs to know about negative coordinates.\n """\n\n def __init__(self, first, dx, dy, second, first_on_top):\n self._first = first\n self._second = second\n self._dx = float(dx)\n self._dy = float(dy)\n self._first_on_top = first_on_top\n\n @property\n def width(self):\n left = min(0.0, self._dx)\n right = max(self._first.width, self._dx + self._second.width)\n return right - left\n\n @property\n def height(self):\n top = min(0.0, self._dy)\n bottom = max(self._first.height, self._dy + self._second.height)\n return bottom - top\n\n def _render_body(self, x, y):\n shift_x = -min(0.0, self._dx)\n shift_y = -min(0.0, self._dy)\n first = self._first._render_body(x + shift_x, y + shift_y)\n second = self._second._render_body(\n x + shift_x + self._dx, y + shift_y + self._dy\n )\n # Document order is painting order, so whatever is on top goes last.\n return (second + first) if self._first_on_top else (first + second)\n\n\nclass _Crop(Image):\n """The `width` x `height` region of `image` starting at (`x`, `y`)."""\n\n def __init__(self, x, y, width, height, image):\n if width < 0 or height < 0:\n raise ValueError("crop needs a width and height of at least 0")\n self._x = float(x)\n self._y = float(y)\n self._w = float(width)\n self._h = float(height)\n self._image = image\n\n @property\n def width(self):\n return self._w\n\n @property\n def height(self):\n return self._h\n\n def _render_body(self, x, y):\n # Shift the source so the requested region lands at (x, y), then\n # clip away everything outside it.\n body = self._image._render_body(x - self._x, y - self._y)\n return _pll_clipped(body, x, y, self._w, self._h)\n\n\nclass _PlaceImage(Image):\n """`image` centered at (cx, cy) on `scene`, cropped to the scene."""\n\n def __init__(self, image, cx, cy, scene):\n self._image = image\n self._cx = float(cx)\n self._cy = float(cy)\n self._scene = scene\n\n @property\n def width(self):\n return self._scene.width\n\n @property\n def height(self):\n return self._scene.height\n\n def _render_body(self, x, y):\n ix = x + self._cx - self._image.width / 2.0\n iy = y + self._cy - self._image.height / 2.0\n body = self._scene._render_body(x, y) + self._image._render_body(ix, iy)\n return _pll_clipped(body, x, y, self.width, self.height)\n\n\nclass _Frame(Image):\n """`image` with a thin outline around its bounding box."""\n\n def __init__(self, image):\n self._image = image\n\n @property\n def width(self):\n return self._image.width\n\n @property\n def height(self):\n return self._image.height\n\n def _render_body(self, x, y):\n # Half-pixel inset so a 1-unit stroke sits inside the bounding box\n # instead of straddling its edge.\n box = (\n \'<rect x="%g" y="%g" width="%g" height="%g" \'\n \'fill="none" stroke="black" stroke-width="1"/>\'\n ) % (\n x + 0.5,\n y + 0.5,\n max(0.0, self.width - 1.0),\n max(0.0, self.height - 1.0),\n )\n return self._image._render_body(x, y) + box\n\n\nclass _Rotate(Image):\n """Rotate `image` counter-clockwise by `angle` degrees (HtDP convention)."""\n\n def __init__(self, angle, image):\n self._angle = float(angle) % 360.0\n self._image = image\n\n @property\n def width(self):\n rad = _math.radians(self._angle)\n w = self._image.width\n h = self._image.height\n return abs(w * _math.cos(rad)) + abs(h * _math.sin(rad))\n\n @property\n def height(self):\n rad = _math.radians(self._angle)\n w = self._image.width\n h = self._image.height\n return abs(w * _math.sin(rad)) + abs(h * _math.cos(rad))\n\n def _render_body(self, x, y):\n # Place the unrotated child centered inside the rotated bounding box,\n # then rotate around its center. SVG\'s `rotate` is clockwise positive,\n # we want counter-clockwise positive, so we negate.\n cw = self._image.width\n ch = self._image.height\n bw = self.width\n bh = self.height\n ox = x + (bw - cw) / 2.0\n oy = y + (bh - ch) / 2.0\n body = self._image._render_body(0, 0)\n return (\n \'<g transform="translate(%g,%g) rotate(%g,%g,%g)">%s</g>\'\n ) % (ox, oy, -self._angle, cw / 2.0, ch / 2.0, body)\n\n\nclass _Scale(Image):\n def __init__(self, factor, image):\n self._fx = float(factor)\n self._fy = float(factor)\n self._image = image\n\n @property\n def width(self):\n return self._image.width * self._fx\n\n @property\n def height(self):\n return self._image.height * self._fy\n\n def _render_body(self, x, y):\n body = self._image._render_body(0, 0)\n return \'<g transform="translate(%g,%g) scale(%g,%g)">%s</g>\' % (\n x, y, self._fx, self._fy, body,\n )\n\n\nclass _Flip(Image):\n def __init__(self, image, horizontal):\n self._image = image\n self._horizontal = horizontal\n\n @property\n def width(self):\n return self._image.width\n\n @property\n def height(self):\n return self._image.height\n\n def _render_body(self, x, y):\n body = self._image._render_body(0, 0)\n if self._horizontal:\n tx = x + self.width\n return \'<g transform="translate(%g,%g) scale(-1,1)">%s</g>\' % (tx, y, body)\n ty = y + self.height\n return \'<g transform="translate(%g,%g) scale(1,-1)">%s</g>\' % (x, ty, body)\n\n\n# -----------------------------------------------------------------------------\n# Public API (exported into user globals by the bootstrap)\n# -----------------------------------------------------------------------------\n\ndef circle(radius, mode, color):\n """Solid or outline circle."""\n return _Circle(radius, mode, color)\n\n\ndef square(side, mode, color):\n """Square with the given side length."""\n return _Rectangle(side, side, mode, color)\n\n\ndef rectangle(width, height, mode, color):\n return _Rectangle(width, height, mode, color)\n\n\ndef ellipse(width, height, mode, color):\n return _Ellipse(width, height, mode, color)\n\n\ndef triangle(side, mode, color):\n """Equilateral triangle pointing up."""\n h = side * _math.sqrt(3) / 2.0\n points = [(side / 2.0, 0.0), (side, h), (0.0, h)]\n return _Polygon(points, mode, color)\n\n\ndef right_triangle(width, height, mode, color):\n """Right triangle with legs `width` (bottom) and `height` (right)."""\n points = [(0.0, height), (width, height), (width, 0.0)]\n return _Polygon(points, mode, color)\n\n\ndef regular_polygon(side, sides, mode, color):\n """Regular polygon with `sides` sides each `side` units long."""\n if sides < 3:\n raise ValueError("regular_polygon needs at least 3 sides")\n radius = side / (2 * _math.sin(_math.pi / sides))\n points = []\n for i in range(sides):\n # start at the top and go clockwise\n angle = -_math.pi / 2.0 + i * 2 * _math.pi / sides\n px = radius + radius * _math.cos(angle)\n py = radius + radius * _math.sin(angle)\n points.append((px, py))\n return _Polygon(points, mode, color)\n\n\ndef star(side, mode, color):\n """5-point star with the given outer "side" length."""\n return star_polygon(side, 5, 2, mode, color)\n\n\ndef star_polygon(side, points_count, step, mode, color):\n """An n-pointed star with the given inner step (e.g. 5/2 -> classic star)."""\n if points_count < 3 or step < 1:\n raise ValueError("invalid star_polygon arguments")\n radius = side / (2 * _math.sin(_math.pi / points_count))\n pts = []\n n = points_count * 2\n inner_radius = radius * _math.cos(step * _math.pi / points_count)\n for i in range(n):\n r = radius if i % 2 == 0 else inner_radius\n angle = -_math.pi / 2.0 + i * _math.pi / points_count\n px = radius + r * _math.cos(angle)\n py = radius + r * _math.sin(angle)\n pts.append((px, py))\n return _Polygon(pts, mode, color)\n\n\ndef line(dx, dy, color):\n """Line going `(dx, dy)` from its top-left anchor."""\n return _Line(dx, dy, color)\n\n\ndef text(value, size, color):\n return _Text(value, size, color)\n\n\ndef beside(*images):\n return _Beside(images)\n\n\ndef above(*images):\n return _Above(images)\n\n\ndef overlay(*images):\n return _Overlay(images)\n\n\ndef underlay(*images):\n """Like overlay, but first arg is on the bottom."""\n return _Overlay(list(reversed(images)))\n\n\ndef beside_align(y_place, *images):\n """Like `beside`, aligned by "top" / "center" / "bottom"."""\n return _Beside(images, _pll_check_place(y_place, _PLL_Y_PLACES, "beside_align"))\n\n\ndef above_align(x_place, *images):\n """Like `above`, aligned by "left" / "center" / "right"."""\n return _Above(images, _pll_check_place(x_place, _PLL_X_PLACES, "above_align"))\n\n\ndef overlay_align(x_place, y_place, *images):\n """Like `overlay`, with both axes aligned explicitly."""\n return _Overlay(\n images,\n _pll_check_place(x_place, _PLL_X_PLACES, "overlay_align"),\n _pll_check_place(y_place, _PLL_Y_PLACES, "overlay_align"),\n )\n\n\ndef underlay_align(x_place, y_place, *images):\n """Like `overlay_align`, but the first image is on the bottom."""\n return _Overlay(\n list(reversed(images)),\n _pll_check_place(x_place, _PLL_X_PLACES, "underlay_align"),\n _pll_check_place(y_place, _PLL_Y_PLACES, "underlay_align"),\n )\n\n\ndef overlay_xy(image1, dx, dy, image2):\n """`image1` on top; `image2` moved `dx` right and `dy` down from it.\n\n Negative offsets move `image2` left / up and the picture grows that way,\n so nothing is ever cut off.\n """\n return _LayeredXY(image1, dx, dy, image2, first_on_top=True)\n\n\ndef underlay_xy(image1, dx, dy, image2):\n """`image1` underneath; `image2` moved `dx` right and `dy` down."""\n return _LayeredXY(image1, dx, dy, image2, first_on_top=False)\n\n\ndef place_image(image, x, y, scene):\n """Put `image`\'s *center* at (x, y) on `scene`, cropped to the scene."""\n return _PlaceImage(image, x, y, scene)\n\n\ndef crop(x, y, width, height, image):\n """The `width` x `height` piece of `image` starting at (x, y)."""\n return _Crop(x, y, width, height, image)\n\n\ndef frame(image):\n """`image` with a thin outline around it, to show its bounding box."""\n return _Frame(image)\n\n\ndef empty_scene(width, height):\n """A blank white scene with an outline, to use with `place_image`."""\n return _Frame(_Rectangle(width, height, "solid", "white"))\n\n\ndef rotate(angle, image):\n return _Rotate(angle, image)\n\n\ndef scale(factor, image):\n return _Scale(factor, image)\n\n\ndef flip_horizontal(image):\n return _Flip(image, horizontal=True)\n\n\ndef flip_vertical(image):\n return _Flip(image, horizontal=False)\n\n\ndef image_width(image):\n return int(_math.ceil(image.width))\n\n\ndef image_height(image):\n return int(_math.ceil(image.height))\n\n\nempty_image = _Rectangle(0, 0, "solid", (0, 0, 0, 0))\n\n\n# Names exported into user globals by the bootstrap. Keep this list explicit\n# so we don\'t accidentally leak helpers (anything starting with `_` would\n# already be filtered, but being explicit avoids drift).\nPLL_IMAGE_EXPORTS = [\n "Image",\n "circle",\n "square",\n "rectangle",\n "ellipse",\n "triangle",\n "right_triangle",\n "regular_polygon",\n "star",\n "star_polygon",\n "line",\n "text",\n "beside",\n "above",\n "overlay",\n "underlay",\n "beside_align",\n "above_align",\n "overlay_align",\n "underlay_align",\n "overlay_xy",\n "underlay_xy",\n "place_image",\n "crop",\n "frame",\n "empty_scene",\n "rotate",\n "scale",\n "flip_horizontal",\n "flip_vertical",\n "image_width",\n "image_height",\n "empty_image",\n]\n';
|
|
@@ -947,11 +947,15 @@ PLL_TABLE_EXPORTS = [
|
|
|
947
947
|
// src/common/reactorLib.py
|
|
948
948
|
var reactorLib_default = '# Python Language Levels: interactive reactors (big-bang / animate).\n#\n# Loaded into Pyodide after imageLib, so `to_draw` handlers can use the\n# image primitives without importing anything.\n#\n# A reactor is a *value*: it holds the handlers plus the current state, and\n# `react` returns a new reactor rather than mutating one. That is what makes\n# stepping backwards possible - the trace is simply the states we have seen,\n# and rewinding is re-drawing one of them.\n#\n# Nothing in here runs an event loop. The extension host owns the clock (and\n# the socket, for the universe client) and calls `_pll_reactor_step` once per\n# event. A loop in here would hold the worker and the whole exec chain for as\n# long as the animation ran, which is the failure mode `Stop` exists for.\n\nimport json as _rx_json\n\n_PLL_DEFAULT_TICK_RATE = 1.0 / 28.0\n\n# Longest trace we keep. A reactor left running for an hour at 28 ticks a\n# second would otherwise hold 100k states alive just so it could rewind.\n_PLL_MAX_TRACE = 10000\n\n_PLL_HANDLER_NAMES = (\n "init",\n "to_draw",\n "on_tick",\n "tick_rate",\n "stop_when",\n "on_key",\n "on_mouse",\n "on_receive",\n "register",\n "title",\n)\n\n\nclass _PllPackage:\n """A new state, plus a message to send to the server."""\n\n __slots__ = ("state", "message")\n\n def __init__(self, state, message):\n self.state = state\n self.message = message\n\n def __repr__(self):\n return "package(%r, %r)" % (self.state, self.message)\n\n\ndef package(state, message):\n """Return `state` to the reactor and send `message` to the server."""\n return _PllPackage(state, message)\n\n\ndef _pll_split_package(result):\n """`(state, messages)` for whatever a handler returned."""\n if isinstance(result, _PllPackage):\n return result.state, (result.message,)\n return result, ()\n\n\nclass Reactor:\n """An interactive program: handlers plus the state they act on.\n\n Build one with `reactor(...)`. Every method that advances it returns a\n *new* Reactor, so a reactor you are holding never changes underneath\n you - `r.react(...)` twice from the same `r` gives the same answer.\n """\n\n __slots__ = ("_h", "_state", "_trace", "_tracing", "_outbox", "_shown")\n\n def __init__(self, handlers, state, trace=None, tracing=False, outbox=()):\n self._h = handlers\n self._state = state\n self._trace = list(trace) if trace else []\n self._tracing = tracing\n self._outbox = tuple(outbox)\n # Set by `interact()` on the reactor it hands back, so a top-level\n # `big_bang(...)` shows its card and not also its repr.\n self._shown = False\n\n @property\n def _pll_already_displayed(self):\n return self._shown\n\n def _pll_image_data(self):\n """Display protocol: a reactor shows the picture for its state.\n\n So `r` at the prompt draws the current frame, the same way a bare\n image or table displays itself.\n """\n return self.draw()._pll_image_data()\n\n # -- plumbing -----------------------------------------------------\n\n def _next(self, state, messages=()):\n trace = self._trace\n if self._tracing and len(trace) < _PLL_MAX_TRACE:\n trace = trace + [state]\n return Reactor(self._h, state, trace, self._tracing, messages)\n\n def _handler(self, name):\n return self._h.get(name)\n\n # -- inspection ---------------------------------------------------\n\n def get_value(self):\n """The current state."""\n return self._state\n\n def draw(self):\n """The image for the current state."""\n return self._h["to_draw"](self._state)\n\n def is_stopped(self):\n """True when `stop_when` says this state is the last one."""\n stop = self._handler("stop_when")\n return bool(stop(self._state)) if stop else False\n\n @property\n def title(self):\n return self._h.get("title") or "reactor"\n\n @property\n def tick_rate(self):\n return self._h.get("tick_rate", _PLL_DEFAULT_TICK_RATE)\n\n @property\n def register(self):\n return self._h.get("register")\n\n def handles(self, name):\n """Whether this reactor has the named handler."""\n return self._handler(name) is not None\n\n def outgoing(self):\n """Messages the last `react` asked to send to the server."""\n return list(self._outbox)\n\n # -- advancing ----------------------------------------------------\n\n def react(self, event):\n """A new reactor with one event applied.\n\n `event` is a dict: `{"kind": "tick"}`, `{"kind": "key", "key": "left"}`,\n `{"kind": "mouse", "x": 1, "y": 2, "event": "button-down"}`, or\n `{"kind": "receive", "message": ...}`. An event this reactor has no\n handler for leaves the state alone.\n """\n kind = event.get("kind") if isinstance(event, dict) else None\n if kind == "tick":\n handler = self._handler("on_tick")\n args = (self._state,)\n elif kind == "key":\n handler = self._handler("on_key")\n args = (self._state, event.get("key", ""))\n elif kind == "mouse":\n handler = self._handler("on_mouse")\n args = (\n self._state,\n event.get("x", 0),\n event.get("y", 0),\n event.get("event", "move"),\n )\n elif kind == "receive":\n handler = self._handler("on_receive")\n args = (self._state, event.get("message"))\n else:\n raise ValueError("react: unknown event kind %r" % (kind,))\n if handler is None:\n return self\n state, messages = _pll_split_package(handler(*args))\n return self._next(state, messages)\n\n def tick(self):\n """Shorthand for `react({"kind": "tick"})`."""\n return self.react({"kind": "tick"})\n\n # -- tracing ------------------------------------------------------\n\n def start_trace(self):\n """A new reactor that records every state it passes through."""\n return Reactor(self._h, self._state, [self._state], True, self._outbox)\n\n def stop_trace(self):\n """A new reactor that stops recording, keeping what it has."""\n return Reactor(self._h, self._state, self._trace, False, self._outbox)\n\n def get_trace(self):\n """The states recorded so far, oldest first."""\n return list(self._trace)\n\n def simulate_trace(self, limit):\n """Run up to `limit` ticks with tracing on, stopping at `stop_when`.\n\n No drawing and no clock: this is the way to test a reactor\'s logic\n at the prompt, or in a test, without watching it.\n """\n if limit < 0:\n raise ValueError("simulate_trace needs a limit of at least 0")\n current = self.start_trace()\n for _ in range(limit):\n if current.is_stopped():\n break\n nxt = current.tick()\n if nxt is current:\n break # no on_tick handler; nothing would ever change\n current = nxt\n return current\n\n def interact(self):\n """Show this reactor in the interactions panel and start it."""\n return _pll_reactor_interact(self)\n\n def __repr__(self):\n return "<reactor %s state=%r>" % (self.title, self._state)\n\n\ndef reactor(**handlers):\n """Build a reactor. `init` and `to_draw` are required.\n\n Handlers: `on_tick(state)`, `on_key(state, key)`,\n `on_mouse(state, x, y, event)`, `on_receive(state, message)`,\n `stop_when(state)`, plus `tick_rate` (seconds), `title`, and `register`\n (a `ws://` URL for the universe client).\n """\n unknown = [k for k in handlers if k not in _PLL_HANDLER_NAMES]\n if unknown:\n raise ValueError(\n "reactor: unknown handler%s %s. Known: %s"\n % (\n "" if len(unknown) == 1 else "s",\n ", ".join(repr(u) for u in sorted(unknown)),\n ", ".join(_PLL_HANDLER_NAMES),\n )\n )\n if "init" not in handlers:\n raise ValueError("reactor: needs an `init` state")\n if "to_draw" not in handlers:\n raise ValueError("reactor: needs a `to_draw` handler")\n for name in ("to_draw", "on_tick", "stop_when", "on_key", "on_mouse", "on_receive"):\n fn = handlers.get(name)\n if fn is not None and not callable(fn):\n raise ValueError("reactor: `%s` must be a function" % name)\n rate = handlers.get("tick_rate", _PLL_DEFAULT_TICK_RATE)\n if not isinstance(rate, (int, float)) or isinstance(rate, bool) or rate <= 0:\n raise ValueError("reactor: `tick_rate` must be a number of seconds above 0")\n state = handlers.pop("init")\n handlers["tick_rate"] = float(rate)\n return Reactor(handlers, state)\n\n\ndef big_bang(init, **handlers):\n """Build a reactor and start it. Racket\'s `big-bang`."""\n handlers["init"] = init\n return reactor(**handlers).interact()\n\n\ndef animate(to_draw, **handlers):\n """Animate `to_draw(n)`, where `n` counts ticks from 0. Racket\'s `animate`."""\n handlers.setdefault("init", 0)\n handlers.setdefault("on_tick", lambda n: n + 1)\n handlers["to_draw"] = to_draw\n return reactor(**handlers).interact()\n\n\nPLL_REACTOR_EXPORTS = [\n "Reactor",\n "reactor",\n "package",\n "big_bang",\n "animate",\n]\n\n\n# -----------------------------------------------------------------------------\n# Running reactors (driven by the extension host, one event per call)\n# -----------------------------------------------------------------------------\n\n_pll_reactors = {}\n_pll_reactor_seq = 0\n\n\nclass _PllRunning:\n """A reactor being interacted with, plus the frames it has been through.\n\n The history is a list of `(reactor, event)` pairs with a cursor, rather\n than a list of states, so rewinding and then playing forward again is\n *replay* - the same reactor values, not a recomputation that could drift\n if a handler is not deterministic. A new event at a rewound cursor\n discards the frames after it, exactly like an editor\'s undo history.\n """\n\n __slots__ = ("frames", "cursor", "dropped")\n\n def __init__(self, reactor_value):\n self.frames = [(reactor_value, None)]\n self.cursor = 0\n # Frames aged out of the front, so the card can still number them.\n self.dropped = 0\n\n @property\n def current(self):\n return self.frames[self.cursor][0]\n\n def seek(self, index):\n self.cursor = max(0, min(index, len(self.frames) - 1))\n return self.current\n\n def step(self, event):\n """Advance by one event. True if the frame was newly computed.\n\n The return value matters for the universe client: a *replayed* frame\n still carries the messages its handler produced the first time, and\n re-sending them because the student dragged the slider back and\n played forward would be wrong.\n """\n ahead = self.cursor + 1\n # Exact redo: the frame we already have came from this same event.\n if ahead < len(self.frames) and self.frames[ahead][1] == event:\n self.cursor = ahead\n return False\n del self.frames[ahead:]\n nxt = self.current.react(event)\n self.frames.append((nxt, event))\n self.cursor = len(self.frames) - 1\n if len(self.frames) > _PLL_MAX_TRACE:\n drop = len(self.frames) - _PLL_MAX_TRACE\n del self.frames[:drop]\n self.dropped += drop\n self.cursor -= drop\n return True\n\n\ndef _pll_reactor_frame(image):\n return {\n "data": image.to_svg(),\n "width": int(image.width) + (1 if image.width % 1 else 0),\n "height": int(image.height) + (1 if image.height % 1 else 0),\n }\n\n\ndef _pll_reactor_view(rid, running):\n """What the host needs to render the current frame."""\n current = running.current\n return {\n "ok": True,\n "id": rid,\n "frame": _pll_reactor_frame(current.draw()),\n "index": running.dropped + running.cursor,\n "length": running.dropped + len(running.frames),\n "at_end": running.cursor == len(running.frames) - 1,\n "stopped": current.is_stopped(),\n "value_repr": repr(current.get_value()),\n }\n\n\ndef _pll_reactor_failure(exc):\n return {\n "ok": False,\n "error_type": type(exc).__name__,\n "error_message": str(exc),\n "traceback": _pll_format_exception(exc),\n }\n\n\ndef _pll_reactor_interact(reactor_value):\n """Register a reactor and emit the card that will drive it."""\n global _pll_reactor_seq\n _pll_reactor_seq += 1\n rid = "r%d" % _pll_reactor_seq\n running = _PllRunning(reactor_value)\n _pll_reactors[rid] = running\n payload = {\n "type": "reactor",\n "id": rid,\n "title": reactor_value.title,\n "tick_rate": reactor_value.tick_rate,\n "ticking": reactor_value.handles("on_tick"),\n "wants_keys": reactor_value.handles("on_key"),\n "wants_mouse": reactor_value.handles("on_mouse"),\n "register": reactor_value.register,\n }\n payload.update(_pll_reactor_view(rid, running))\n payload.pop("ok", None)\n _pll_push(payload)\n reactor_value._shown = True\n return reactor_value\n\n\ndef _pll_reactor_step(rid, event_json):\n """Apply one event to a running reactor and return the new frame."""\n running = _pll_reactors.get(rid)\n if running is None:\n return {"ok": False, "gone": True}\n try:\n event = _rx_json.loads(event_json)\n computed = running.step(event)\n view = _pll_reactor_view(rid, running)\n outgoing = []\n for message in running.current.outgoing() if computed else ():\n try:\n outgoing.append(_rx_json.dumps(message))\n except (TypeError, ValueError):\n return {\n "ok": False,\n "error_type": "TypeError",\n "error_message": (\n "package(...) can only send values the server can read: "\n "numbers, strings, True/False, None, lists and dicts of "\n "those. Got %r." % (message,)\n ),\n "traceback": "",\n }\n view["messages"] = outgoing\n return view\n except BaseException as e:\n return _pll_reactor_failure(e)\n\n\ndef _pll_reactor_seek(rid, index):\n """Show an earlier (or later) frame without applying an event."""\n running = _pll_reactors.get(rid)\n if running is None:\n return {"ok": False, "gone": True}\n try:\n running.seek(int(index) - running.dropped)\n return _pll_reactor_view(rid, running)\n except BaseException as e:\n return _pll_reactor_failure(e)\n\n\ndef _pll_reactor_dispose(rid):\n """Forget a reactor, so its states and trace can be collected."""\n _pll_reactors.pop(rid, None)\n return True\n\n';
|
|
949
949
|
|
|
950
|
+
// src/common/examplarLib.py
|
|
951
|
+
var examplarLib_default = '# Python Language Levels: Examplar support.\n#\n# Examplar (Wrenn, Politz et al.) assesses a *test suite* rather than an\n# implementation. Students write tests first; those tests are run against\n# known-good implementations ("wheats"), where they must all pass, and\n# known-bad ones ("chaffs"), where each must be caught by at least one\n# failing test. A failure on a wheat means the student\'s expectation is\n# wrong; a chaff nobody catches means their suite has a gap.\n#\n# Two primitives live here, both used by the command line and the editor:\n#\n# _pll_examplar_build(sources_json) -> bundle dict (authoring)\n# _pll_examplar_run(test_src, bundle_json) -> results (both hosts)\n#\n# Implementations travel as `.pyc` bytecode, compiled *by this interpreter*.\n# That is deliberate: bytecode is tied to the Python minor version, and\n# compiling inside Pyodide makes the magic number match by construction\n# rather than by asking course staff to keep a matching CPython around.\n#\n# Bytecode hides the source from `inspect.getsource`, and nothing more -\n# `dis` and `co_consts` still tell the whole story to anyone who looks.\n# That is accepted, not overlooked: the autograder holds the grade, so this\n# check needs to be fast and honest rather than secret.\n\nimport ast as _ex_ast\nimport base64 as _ex_b64\nimport importlib.util as _ex_util\nimport marshal as _ex_marshal\n\nEXAMPLAR_FORMAT = 2\n\n\ndef _pll_examplar_magic():\n """Hex of this interpreter\'s bytecode magic, for bundle metadata."""\n return _ex_util.MAGIC_NUMBER.hex()\n\n\ndef _pll_examplar_top_level_names(source, filename):\n """Public top-level names a source file defines.\n\n From the AST, so nothing is executed at authoring time - a wheat is\n still ordinary code someone wrote, and building a bundle should not run\n it.\n """\n names = set()\n for node in _ex_ast.parse(source, filename=filename).body:\n if isinstance(node, (_ex_ast.FunctionDef, _ex_ast.AsyncFunctionDef, _ex_ast.ClassDef)):\n if not node.name.startswith("_"):\n names.add(node.name)\n elif isinstance(node, _ex_ast.Assign):\n for target in node.targets:\n if isinstance(target, _ex_ast.Name) and not target.id.startswith("_"):\n names.add(target.id)\n return names\n\n\ndef _pll_examplar_compile(source, filename):\n """Source -> base64 of a `.pyc` payload (marshalled code, no header).\n\n The import-machinery header is left off on purpose: these are loaded by\n `marshal.loads` rather than by an import hook, so the magic number is\n carried once in the bundle\'s metadata instead of on every blob.\n """\n code = compile(source, filename, "exec")\n return _ex_b64.b64encode(_ex_marshal.dumps(code)).decode("ascii")\n\n\ndef _pll_examplar_build(sources_json):\n """Compile wheats and chaffs into a bundle.\n\n `sources_json` is `{"wheats": {id: source},\n "chaffs": {function: {id: source}}}`.\n Returns a dict with `ok`, and either `bundle` or `error`.\n\n Chaffs are grouped by the function they break, because the student\'s\n report is per function: *these* tests of `initials` caught *these*\n implementations of `initials`. Nothing can infer that grouping reliably\n - a chaff is a whole file, and the functions it leaves alone still differ\n from the wheat\'s by whitespace - so the author states it, by which\n directory the chaff lives in.\n\n Every implementation must define the same public names: a suite is run\n against all of them interchangeably, so one that is missing a function\n would fail for a reason that has nothing to do with the student.\n """\n import json as _ex_json\n\n try:\n sources = _ex_json.loads(sources_json)\n except ValueError as e:\n return {"ok": False, "error": "could not read the sources (%s)" % e}\n\n provides = None\n\n def compile_one(filename, source):\n """-> (blob, error). Also checks the file defines the same names."""\n nonlocal provides\n try:\n names = _pll_examplar_top_level_names(source, filename)\n blob = _pll_examplar_compile(source, filename)\n except SyntaxError as e:\n return None, "%s does not parse: %s (line %s)" % (filename, e.msg, e.lineno)\n if provides is None:\n provides = names\n elif names != provides:\n detail = []\n if provides - names:\n detail.append("missing " + ", ".join(sorted(provides - names)))\n if names - provides:\n detail.append("unexpected " + ", ".join(sorted(names - provides)))\n return None, (\n "%s defines a different set of names than the others (%s). "\n "Every wheat and chaff has to define the same functions."\n % (filename, "; ".join(detail))\n )\n return blob, None\n\n wheats = []\n for ident, source in sorted((sources.get("wheats") or {}).items()):\n blob, error = compile_one("wheats/%s.py" % ident, source)\n if error:\n return {"ok": False, "error": error}\n wheats.append({"id": ident, "pyc": blob})\n\n chaffs = []\n for function, group in sorted((sources.get("chaffs") or {}).items()):\n for ident, source in sorted((group or {}).items()):\n blob, error = compile_one("chaffs/%s/%s.py" % (function, ident), source)\n if error:\n return {"ok": False, "error": error}\n chaffs.append({"id": ident, "targets": function, "pyc": blob})\n\n if not wheats:\n return {"ok": False, "error": "a bundle needs at least one wheat"}\n if not chaffs:\n return {"ok": False, "error": "a bundle needs at least one chaff"}\n\n # A chaff directory that is not one of the functions would silently never\n # be reported - no card would ever mention it.\n targeted = {entry["targets"] for entry in chaffs}\n provides = provides or set()\n stray = sorted(targeted - provides)\n if stray:\n return {\n "ok": False,\n "error": (\n "chaffs/%s is not one of the functions in this bundle (%s). "\n "A chaff goes in a directory named after the function it breaks."\n % (stray[0], ", ".join(sorted(provides)) or "none")\n ),\n }\n # And a function with no chaffs gets a card that can never say anything\n # about how thorough its tests are, which is half of what a card is for.\n barren = sorted(provides - targeted)\n if barren:\n return {\n "ok": False,\n "error": (\n "%s has no chaffs, so nothing would ever measure how thorough a "\n "student\'s tests of it are. Add chaffs/%s/1.py."\n % (barren[0], barren[0])\n ),\n }\n\n import sys as _ex_sys\n\n return {\n "ok": True,\n "bundle": {\n "examplar": EXAMPLAR_FORMAT,\n "built": {\n "python": _ex_sys.version.split()[0],\n "magic": _pll_examplar_magic(),\n },\n "provides": sorted(provides),\n "wheats": wheats,\n "chaffs": chaffs,\n },\n }\n\n\ndef _pll_examplar_free_names(fn_node):\n """Names a function body reads without binding them itself."""\n bound = {arg.arg for arg in fn_node.args.args}\n bound.update(arg.arg for arg in getattr(fn_node.args, "kwonlyargs", []))\n read = set()\n for node in _ex_ast.walk(fn_node):\n if isinstance(node, _ex_ast.Name):\n if isinstance(node.ctx, _ex_ast.Store):\n bound.add(node.id)\n else:\n read.add(node.id)\n return read - bound\n\n\ndef _pll_examplar_attribution(test_source, provides):\n """Which provided names each `test_*` function exercises.\n\n This decides which card a test appears on, so it has to follow the\n student\'s own helpers. A test written as\n\n def check(name, expected):\n assert initials(name) == expected\n\n def test_ada():\n check("Ada Lovelace", "A.L.")\n\n reads `check`, not `initials`, and a direct free-variable scan would\n file it under nothing at all and quietly leave it off every card. So the\n reachable set is closed over the file\'s own top-level functions.\n\n It stops at a provided name rather than descending into it: during the\n phase that name is the *bundle\'s* function, so whatever the student\'s\n version of it happens to call is beside the point.\n\n Not needed for the verdicts themselves, so a parse failure is not fatal.\n """\n out = {}\n try:\n tree = _ex_ast.parse(test_source)\n except SyntaxError:\n return out\n wanted = set(provides)\n reads = {}\n for node in tree.body:\n if isinstance(node, (_ex_ast.FunctionDef, _ex_ast.AsyncFunctionDef)):\n reads[node.name] = _pll_examplar_free_names(node)\n for name in reads:\n if not name.startswith("test_"):\n continue\n reached = set()\n seen = set()\n pending = [name]\n while pending:\n current = pending.pop()\n if current in seen:\n continue\n seen.add(current)\n for read in reads.get(current, ()):\n reached.add(read)\n if read in reads and read not in wanted:\n pending.append(read)\n out[name] = sorted(reached & wanted)\n return out\n\n\ndef _pll_examplar_outcome(exc):\n """How a test ended, and what to say about it."""\n if exc is None:\n return {"outcome": "pass", "message": None}\n if isinstance(exc, AssertionError):\n # Kept for `--verify`, whose reader is the author. A student is shown\n # the test\'s name and not this - see `_pll_examplar_compile_tests`.\n return {"outcome": "fail", "message": str(exc) or "assertion failed"}\n return {\n "outcome": "error",\n "message": "%s: %s" % (type(exc).__name__, exc),\n }\n\n\n# The top-level statements that give a file its *names*. Everything else is\n# the student\'s program, and the phase deliberately does not run that: it\n# runs properly a moment later, once, with the workspace mounted. Running it\n# here as well would mean one copy of every side effect per implementation -\n# four `print`s, four reactors - and a top-level `input()` would block the\n# check forever, because nothing is listening for stdin during it.\n_PLL_EXAMPLAR_DEFINITIONS = (\n _ex_ast.Import,\n _ex_ast.ImportFrom,\n _ex_ast.FunctionDef,\n _ex_ast.AsyncFunctionDef,\n _ex_ast.ClassDef,\n _ex_ast.Assign,\n _ex_ast.AnnAssign,\n _ex_ast.AugAssign,\n)\n\n\ndef _pll_examplar_compile_tests(test_source, filename):\n """Compile the student\'s definitions, one statement at a time.\n\n Through pytest\'s assertion rewriting, so a failure carries the values it\n saw: `assert \'HI!\' == \'hi!\'` rather than "assertion failed".\n\n That message is for the **author**, through `--verify`, where a wheat\n failure means one of their own tests is wrong and there is nothing to\n give away. It never reaches a student: the card names the test and stops\n there, because the rewritten form states the correct answer, and a card\n that hands that over is an oracle rather than a check. The host drops it\n when building the card; it is computed here because the same primitive\n serves both readers.\n\n One code object per statement rather than one for the module, so a\n definition that cannot be loaded here costs only itself instead of the\n whole verdict. `DATA = open("data.csv").read()` is the case that matters:\n the phase runs with the workspace unmounted, so that line cannot work,\n and losing all of a student\'s feedback over it would be a poor trade.\n\n Compiled once and reused for every implementation, so N chaffs still\n cost one compile. Falls back to a plain parse when pytest is not loaded,\n so the verdicts still work - just with poorer messages.\n """\n tree = _ex_ast.parse(test_source, filename=filename)\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n\n rewrite_asserts(tree, test_source.encode("utf-8"), module_path=filename)\n # Same trap as `_pll_run_tests`: do not call `fix_missing_locations`,\n # which copies parent positions onto pytest\'s injected nodes and\n # produces ranges Python 3.12+ rejects.\n _pll_fix_ast_ranges(tree)\n except Exception:\n # Rewriting can leave the tree half-modified, so start again from the\n # source rather than compiling whatever it got to.\n tree = _ex_ast.parse(test_source, filename=filename)\n pieces = []\n for node in tree.body:\n if isinstance(node, _PLL_EXAMPLAR_DEFINITIONS):\n pieces.append(\n compile(_ex_ast.Module(body=[node], type_ignores=[]), filename, "exec")\n )\n return pieces\n\n\ndef _pll_examplar_run_one(test_pieces, code_blob, provided=(), only=None):\n """Run every `test_*` the student defined against one implementation.\n\n Their definitions are loaded first and the implementation second, so a\n name the implementation provides replaces whatever the student wrote.\n That is the Examplar semantics: their tests are judged against this\n code, not against their own attempt.\n\n A definition that raises is skipped rather than fatal, for the reason\n `_pll_examplar_compile_tests` splits them up. Any test that needed it\n then raises its own `NameError`, which the host reports as a test that\n could not run - not as a test that expects the wrong answer.\n\n `only` restricts which tests are run, and chaffs use it: a chaff of\n `shout` must be judged by the tests of `shout` and nothing else. A test\n of `total` fails on it too - a broken `total` test fails on everything -\n and counting that as having caught it would credit the student for a\n signal that has nothing to do with the function.\n """\n namespace = {"__name__": "__main__"}\n for piece in test_pieces:\n try:\n exec(piece, namespace)\n except Exception:\n # Deliberately `Exception`, not `BaseException`: a\n # `KeyboardInterrupt` from the Stop button has to get out.\n continue\n # Which provided names the student has written themselves, recorded\n # *before* the overlay replaces them. The host uses this to decide\n # whether running their tests against their own code makes sense yet:\n # early in the exercise there is no implementation, and doing so would\n # just spray NameErrors.\n student_defines = sorted(\n name for name in provided if callable(namespace.get(name)) or name in namespace\n )\n try:\n exec(_ex_marshal.loads(_ex_b64.b64decode(code_blob)), namespace)\n except BaseException as e:\n return {\n "loaded": False,\n "error_type": type(e).__name__,\n "error_message": "the implementation could not be loaded (%s)" % e,\n "traceback": "",\n "tests": {},\n "student_defines": student_defines,\n }\n\n results = {}\n for name in sorted(namespace):\n if not name.startswith("test_"):\n continue\n if only is not None and name not in only:\n continue\n fn = namespace[name]\n if not callable(fn):\n continue\n try:\n fn()\n results[name] = _pll_examplar_outcome(None)\n except BaseException as e:\n results[name] = _pll_examplar_outcome(e)\n return {"loaded": True, "tests": results, "student_defines": student_defines}\n\n\ndef _pll_examplar_run(test_source, bundle_json):\n """Run a student\'s suite against a bundle, per function and in two phases.\n\n Returns a dict the host turns into one report per provided function.\n Verdicts, for each function separately:\n * a test failing on any wheat -> that test is wrong\n * a chaff with no failing test -> the suite has a gap there\n\n The second phase only happens where the first one *passed*, and it is\n decided per function: `initials` having a wrong test says nothing about\n how well `longest` is tested, so it must not hold that report back. A\n test that disagrees with a correct implementation is wrong, and a wrong\n test fails on *everything* - so it would "catch" every chaff of its\n function, and the number would be an artifact of the bug rather than a\n measure of the suite. A test that *raised* is no better: it raises the\n same way on every implementation. Gating means those chaffs are not even\n run, so nothing about them can be reported before it would mean\n something.\n """\n import json as _ex_json\n\n try:\n bundle = _ex_json.loads(bundle_json)\n except ValueError as e:\n return {"ok": False, "error": "the bundle is not valid JSON (%s)" % e}\n\n if bundle.get("examplar") != EXAMPLAR_FORMAT:\n return {\n "ok": False,\n "error": "this bundle is format %r, but this PLL understands %d"\n % (bundle.get("examplar"), EXAMPLAR_FORMAT),\n }\n built = bundle.get("built") or {}\n if built.get("magic") and built["magic"] != _pll_examplar_magic():\n return {\n "ok": False,\n "error": (\n "this bundle was built for Python %s and cannot run here "\n "(Python %s). It needs rebuilding with a matching pll-python."\n % (built.get("python", "?"), __import__("sys").version.split()[0])\n ),\n }\n\n provides = list(bundle.get("provides") or ())\n # The student\'s own file failing to parse is their problem to fix, and\n # the normal run reports it properly - say so once rather than once per\n # implementation.\n try:\n test_pieces = _pll_examplar_compile_tests(test_source, "hw.py")\n except SyntaxError as e:\n return {\n "ok": False,\n "error": "your file does not parse yet: %s (line %s)" % (e.msg, e.lineno),\n }\n\n attribution = _pll_examplar_attribution(test_source, provides)\n result = {\n "ok": True,\n "provides": provides,\n "attribution": attribution,\n "wheats": [],\n "chaffs": [],\n "chaffs_skipped": [],\n }\n for entry in bundle.get("wheats") or []:\n ran = _pll_examplar_run_one(test_pieces, entry.get("pyc", ""), provides)\n ran["id"] = entry.get("id", "?")\n result["wheats"].append(ran)\n\n # A wheat that will not load is a broken bundle, not a student\'s problem,\n # and there is nothing to measure against - so no phase two anywhere.\n if any(not wheat["loaded"] for wheat in result["wheats"]):\n result["chaffs_skipped"] = sorted(provides)\n return result\n\n # Phase one, per function: every test that exercises it has to have\n # *passed* on every wheat. Nothing weaker counts - a test that raised did\n # not pass either.\n settled = set()\n for function in provides:\n tests = [name for name, names in attribution.items() if function in names]\n if not tests:\n # No tests for it, so nothing to gate and nothing to measure. The\n # host says "no tests yet" rather than "caught 0 of 3", which\n # would read as a failure at something they have not started.\n continue\n if all(\n wheat["tests"].get(name, {}).get("outcome") == "pass"\n for wheat in result["wheats"]\n for name in tests\n ):\n settled.add(function)\n\n for entry in bundle.get("chaffs") or []:\n function = entry.get("targets")\n if function not in settled:\n continue\n # Judged by the tests of *its* function only. A test of another one\n # fails on it as well, and crediting that would score the student\n # for a signal that says nothing about this chaff.\n mine = {name for name, names in attribution.items() if function in names}\n ran = _pll_examplar_run_one(test_pieces, entry.get("pyc", ""), provides, mine)\n ran["id"] = entry.get("id", "?")\n ran["targets"] = function\n result["chaffs"].append(ran)\n result["chaffs_skipped"] = sorted(set(provides) - settled)\n return result\n';
|
|
952
|
+
|
|
950
953
|
// src/common/pyodideRunner.ts
|
|
951
954
|
var PYODIDE_BOOTSTRAP_PY = pyodideBootstrap_default;
|
|
952
955
|
var PLL_IMAGE_LIB_PY = imageLib_default;
|
|
953
956
|
var PLL_TABLE_LIB_PY = tableLib_default;
|
|
954
957
|
var PLL_REACTOR_LIB_PY = reactorLib_default;
|
|
958
|
+
var PLL_EXAMPLAR_LIB_PY = examplarLib_default;
|
|
955
959
|
var NETWORK_IMPORT_RE = /(^|\n)[ \t]*(?:import|from)[ \t]+(?:pandas|requests|urllib|urllib3|httpx|aiohttp|http)\b/;
|
|
956
960
|
var PYODIDE_HTTP_PATCH_PY = "import pyodide_http as _pll_ph; _pll_ph.patch_all()";
|
|
957
961
|
var PYODIDE_INSTALL_PY = `
|
|
@@ -1121,6 +1125,7 @@ function createWorkerHost(adapter) {
|
|
|
1121
1125
|
instance.runPython(PLL_IMAGE_LIB_PY);
|
|
1122
1126
|
instance.runPython(PLL_TABLE_LIB_PY);
|
|
1123
1127
|
instance.runPython(PLL_REACTOR_LIB_PY);
|
|
1128
|
+
instance.runPython(PLL_EXAMPLAR_LIB_PY);
|
|
1124
1129
|
instance.runPython(PYODIDE_INSTALL_PY);
|
|
1125
1130
|
enableTypeChecking(instance);
|
|
1126
1131
|
ensureWorkDir(instance.FS);
|
|
@@ -1288,6 +1293,21 @@ function createWorkerHost(adapter) {
|
|
|
1288
1293
|
adapter.post({ id: data.id, type: "reactorDisposed" });
|
|
1289
1294
|
break;
|
|
1290
1295
|
}
|
|
1296
|
+
case "examplarBuild": {
|
|
1297
|
+
const result = callPython("_pll_examplar_build", [
|
|
1298
|
+
data.sources
|
|
1299
|
+
]);
|
|
1300
|
+
adapter.post({ id: data.id, type: "examplarBuilt", result });
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
case "examplarRun": {
|
|
1304
|
+
const result = callPython("_pll_examplar_run", [
|
|
1305
|
+
data.testSource,
|
|
1306
|
+
data.bundle
|
|
1307
|
+
]);
|
|
1308
|
+
adapter.post({ id: data.id, type: "examplarRan", result });
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1291
1311
|
case "mountWorkspace": {
|
|
1292
1312
|
mountWorkspaceFiles(ready().FS, data.files);
|
|
1293
1313
|
adapter.post({ id: data.id, type: "workspaceReady" });
|