pll-python 0.1.0 → 0.2.0
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 +1555 -1179
- package/package.json +2 -2
- package/worker.cjs +44 -781
package/worker.cjs
CHANGED
|
@@ -62,6 +62,9 @@ function isWritebackName(name) {
|
|
|
62
62
|
function utf8ByteLength(text) {
|
|
63
63
|
return new TextEncoder().encode(text).byteLength;
|
|
64
64
|
}
|
|
65
|
+
function contentsByteLength(contents) {
|
|
66
|
+
return typeof contents === "string" ? utf8ByteLength(contents) : contents.byteLength;
|
|
67
|
+
}
|
|
65
68
|
|
|
66
69
|
// src/common/memfsWorkspace.ts
|
|
67
70
|
var PLL_WORK_DIR = "/home/pyodide/pll_workspace";
|
|
@@ -116,7 +119,7 @@ function mountWorkspaceFiles(FS, files) {
|
|
|
116
119
|
if (!isSafeBasename(file.name)) {
|
|
117
120
|
continue;
|
|
118
121
|
}
|
|
119
|
-
if (
|
|
122
|
+
if (contentsByteLength(file.contents) > MAX_FILE_BYTES) {
|
|
120
123
|
continue;
|
|
121
124
|
}
|
|
122
125
|
const path = joinCwd(FS, file.name);
|
|
@@ -152,7 +155,7 @@ function collectChangedWorkspaceFiles(FS) {
|
|
|
152
155
|
continue;
|
|
153
156
|
}
|
|
154
157
|
const prev = lastMounted.get(name);
|
|
155
|
-
if (prev && prev.contents === contents && prev.mtimeMs === mtimeMs(stat)) {
|
|
158
|
+
if (prev && typeof prev.contents === "string" && prev.contents === contents && prev.mtimeMs === mtimeMs(stat)) {
|
|
156
159
|
continue;
|
|
157
160
|
}
|
|
158
161
|
out.push({ name, contents });
|
|
@@ -161,797 +164,27 @@ function collectChangedWorkspaceFiles(FS) {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
// 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';
|
|
167
|
+
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 types as _pll_types\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 # Referenced by name in the AST that `_PllDataclassChecks` injects.\n _pll_initial_globals["_pll_check_dataclass_fields"] = _pll_check_dataclass_fields\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_is_dataclass_decorator(node):\n """True for `@dataclass`, `@dataclasses.dataclass` and calls to either."""\n if isinstance(node, _ast.Call):\n node = node.func\n if isinstance(node, _ast.Name):\n return node.id == "dataclass"\n if isinstance(node, _ast.Attribute):\n return node.attr == "dataclass"\n return False\n\n\nclass _PllDataclassChecks(_ast.NodeTransformer):\n """Have every `@dataclass` check its field types when constructed.\n\n typeguard instruments what it can see in the source, but `@dataclass`\n writes `__init__` *afterwards* - so `Dog(5, 3)` with `name: str` was\n accepted without a word, which is the opposite of the point at\n `#level beginner`.\n\n The checker goes on the front of the decorator list, which makes it the\n outermost one and therefore the last to run, so it sees the `__init__`\n that `@dataclass` generated rather than the class before it existed.\n """\n\n def visit_ClassDef(self, node):\n self.generic_visit(node)\n if any(_pll_is_dataclass_decorator(d) for d in node.decorator_list):\n node.decorator_list.insert(\n 0, _ast.Name(id="_pll_check_dataclass_fields", ctx=_ast.Load())\n )\n return node\n\n\ndef _pll_check_dataclass_fields(cls):\n """Wrap a dataclass\'s `__init__` so its fields are checked.\n\n Checked after the original `__init__` has run, so defaults,\n `field(default_factory=...)` and `__post_init__` have all had their\n say and the values checked are the ones the instance really holds.\n\n Annotations are resolved at construction rather than at decoration,\n because a recursive definition names a class that does not exist yet\n (`rest: "NumList"`). A resolution that fails is not cached: it may well\n succeed once the rest of the file has run.\n\n The namespace to resolve them in is taken from the frame that applied\n the decorator - the student\'s own globals - and not left to\n `get_type_hints`, which would look up `sys.modules[cls.__module__]`.\n That is Pyodide\'s `__main__`, a different dict from the session\'s, so\n every recursive annotation failed to resolve and its field went\n unchecked.\n """\n if not _PLL_TYPE_CHECK or not _PLL_TYPEGUARD_READY:\n return cls\n memo_type = _pll_initial_globals.get("_pll_tg_memo")\n check = _pll_initial_globals.get("_pll_tg_check_assign")\n if memo_type is None or check is None:\n return cls\n\n try:\n defining_globals = _sys._getframe(1).f_globals\n except Exception:\n defining_globals = getattr(_sys.modules.get(cls.__module__), "__dict__", {})\n\n original = cls.__init__\n cache = []\n\n def __init__(self, *args, **kwargs):\n original(self, *args, **kwargs)\n if not cache:\n import typing as _pll_typing\n\n try:\n cache.append(\n _pll_typing.get_type_hints(cls, globalns=dict(defining_globals))\n )\n except Exception:\n # Not resolvable yet (or at all). Construct without\n # checking rather than failing on the annotation.\n return\n memo = memo_type(defining_globals, {})\n for field_name, hint in cache[0].items():\n if not hasattr(self, field_name):\n continue\n check(getattr(self, field_name), [(field_name, hint)], memo)\n\n __init__.__name__ = "__init__"\n __init__.__qualname__ = "%s.__init__" % cls.__qualname__\n cls.__init__ = __init__\n return cls\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 _PllDataclassChecks().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# Reading a source that is either a URL or a file next to the program\n# -----------------------------------------------------------------------------\n#\n# Shared by `load_table` and `load_image`, so "https:// means the network,\n# anything else means a file" is decided in exactly one place and both report\n# the same way when it goes wrong. It lives in the bootstrap because the\n# libraries are exec\'d into these globals afterwards, in the same way\n# examplarLib borrows `_pll_fix_ast_ranges`.\n\nimport re as _pll_src_re\n\n_PLL_SCHEME_RE = _pll_src_re.compile(r"^([A-Za-z][A-Za-z0-9+.\\-]*)://")\n\n\ndef _pll_source_is_url(source):\n """True when `source` names an address rather than a file."""\n match = _PLL_SCHEME_RE.match(source)\n return match is not None and match.group(1).lower() in ("http", "https")\n\n\ndef _pll_fetch_bytes(url, what):\n """GET `url` synchronously and return the body as bytes.\n\n Uses `XMLHttpRequest` rather than `pyodide.http.open_url`, because that\n decodes to text and an image is bytes - one path has to serve both.\n Synchronous XHR is fine here: Pyodide runs in a worker, never on a\n page\'s main thread.\n\n `overrideMimeType` is what keeps this portable. A browser would decode\n the body as UTF-8 and mangle every byte above 0x7f, so it is asked for\n `x-user-defined`, which maps bytes 0x80-0xff to U+F780-U+F7FF; masking\n with 0xff undoes that. On the desktop the polyfill ignores the call and\n hands back latin-1, where the mask is the identity. Same two lines of\n Python either way.\n """\n try:\n from js import XMLHttpRequest as _Xhr\n except ImportError:\n raise OSError(\n "%s cannot reach the network, so it cannot read %s." % (what, url)\n ) from None\n xhr = _Xhr.new()\n try:\n xhr.open("GET", url, False)\n xhr.overrideMimeType("text/plain; charset=x-user-defined")\n xhr.send(None)\n except Exception as e:\n # A failed cross-origin request looks like this, and it is the most\n # likely cause by far, so say so rather than repeating the browser\'s\n # famously unhelpful wording.\n raise OSError(\n "%s could not reach %s (%s). If that address is not your own, it "\n "may not allow other sites to read it." % (what, url, e)\n ) from None\n if xhr.status != 200:\n raise OSError(\n "%s could not read %s: the server answered %d."\n % (what, url, xhr.status)\n )\n return bytes(ord(c) & 0xFF for c in xhr.responseText)\n\n\ndef _pll_read_source(source, what, binary=False):\n """Read `source` - a URL or a path beside the program - and return it.\n\n Returns bytes when `binary`, otherwise text decoded as UTF-8.\n """\n if not isinstance(source, str):\n raise TypeError(\n "%s needs a file name or a URL as a string, not %r"\n % (what, type(source).__name__)\n )\n stripped = source.strip()\n if not stripped:\n raise ValueError("%s needs a file name or a URL; got an empty string" % what)\n\n if _pll_source_is_url(stripped):\n data = _pll_fetch_bytes(stripped, what)\n else:\n scheme = _PLL_SCHEME_RE.match(stripped)\n if scheme is not None:\n raise ValueError(\n "%s can read an https:// address or a file next to your "\n "program, but not a %s:// one." % (what, scheme.group(1))\n )\n try:\n with open(stripped, "rb") as handle:\n data = handle.read()\n except FileNotFoundError:\n raise FileNotFoundError(\n "There is no file called %r next to your program. Check the "\n "spelling, or pass an https:// address instead." % stripped\n ) from None\n except IsADirectoryError:\n raise IsADirectoryError("%r is a folder, not a file." % stripped) from None\n if binary:\n return data\n try:\n return data.decode("utf-8")\n except UnicodeDecodeError:\n raise ValueError(\n "%s could not read %r as text - it does not look like a text file."\n % (what, source)\n ) from None\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 # The tests run inside a real module, registered under the name their\n # classes will report as `__module__`.\n #\n # `dataclasses` resolves a *string* annotation - `rest: "NumList"`, the\n # shape of every recursive data definition - by looking that module up:\n # `sys.modules.get(cls.__module__).__dict__`. With nothing registered\n # that is `None.__dict__`, and the whole test phase died with\n # `AttributeError: \'NoneType\' object has no attribute \'__dict__\'`.\n #\n # The module\'s own `__dict__` is used as the globals, rather than a copy\n # of them, so a forward reference resolves to the student\'s class as\n # soon as they define it.\n module = _pll_types.ModuleType("__pll_test__")\n ns = module.__dict__\n ns.update(_pll_initial_globals)\n ns["__name__"] = "__pll_test__"\n ns["__file__"] = display_name\n _sys.modules["__pll_test__"] = module\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 if scope.kind == "class":\n # A name bound in a class body is an *attribute*, not a\n # variable. `id: int` in a dataclass declares a field, and\n # `id` everywhere else still finds the built-in - so there\n # is nothing being shadowed, and telling a student to\n # rename the field was simply wrong. The class\'s own name\n # is bound in the enclosing scope and is still checked\n # there, so `class list:` is still caught.\n continue\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
168
|
|
|
166
169
|
// src/common/imageLib.py
|
|
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';
|
|
170
|
+
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 base64 as _pll_img_b64\nimport math as _math\nimport re as _pll_img_re\n\n\n# -----------------------------------------------------------------------------\n# Color handling\n# -----------------------------------------------------------------------------\n\n#: Colour strings that are not names: `#abc`, `#aabbcc`, `#aabbccdd`, and\n#: the functional forms, so `rgb(1, 2, 3)` keeps working.\n_PLL_COLOR_HEX_RE = _pll_img_re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")\n_PLL_COLOR_FN_RE = _pll_img_re.compile(r"^(?:rgb|rgba|hsl|hsla)\\(.+\\)$")\n#: A colour *name*. Not checked against a list of the real ones - see\n#: `_pll_check_color` for why - so this only says it is shaped like a word.\n_PLL_COLOR_NAME_RE = _pll_img_re.compile(r"^[A-Za-z]+$")\n\n_PLL_MODES = ("solid", "outline")\n\n\ndef _pll_check_color(color, who):\n """Reject anything that is not a colour, naming the caller.\n\n SVG ignores a paint value it cannot parse, so before this an\n `rectangle(30, 40, "solid", 50)` drew an invisible rectangle and said\n nothing at all. Checked here, at construction, rather than when the\n picture renders, so the error points at the line that made the mistake.\n\n A *name* is only checked for shape, not against the list of real CSS\n colours: getting that list slightly wrong would reject a colour that\n works, which is worse than the typo it would catch. So `"rd"` still\n draws nothing - see the note in the readme.\n """\n if isinstance(color, str):\n if (\n _PLL_COLOR_NAME_RE.match(color)\n or _PLL_COLOR_HEX_RE.match(color)\n or _PLL_COLOR_FN_RE.match(color)\n ):\n return color\n raise ValueError(\n "%s: %r is not a colour. Use a name like \\"red\\", a hex code like "\n "\\"#ff0000\\", or (red, green, blue) numbers from 0 to 255." % (who, color)\n )\n if isinstance(color, (tuple, list)) and len(color) in (3, 4):\n for i, part in enumerate(color):\n if isinstance(part, bool) or not isinstance(part, (int, float)):\n raise ValueError(\n "%s: a colour\'s parts have to be numbers, but %r is %s."\n % (who, part, type(part).__name__)\n )\n # The fourth part is opacity, which is written either way round.\n limit = 255 if i < 3 or part > 1 else 1\n if not 0 <= part <= limit:\n raise ValueError(\n "%s: the %s part of a colour runs from 0 to %g, but it is %r."\n % (who, "red green blue opacity".split()[i], limit, part)\n )\n return color\n raise ValueError(\n "%s: %r is not a colour. Use a name like \\"red\\", a hex code like "\n "\\"#ff0000\\", or (red, green, blue) numbers from 0 to 255." % (who, color)\n )\n\n\ndef _pll_check_mode(mode, who):\n """`solid` or `outline`, and nothing else.\n\n Same silence as a bad colour: anything unrecognised used to fall\n through to solid, so a misspelled "outilne" quietly filled the shape.\n """\n if mode not in _PLL_MODES:\n raise ValueError(\n "%s: expected \\"solid\\" or \\"outline\\", got %r" % (who, mode)\n )\n return mode\n\n\ndef _pll_px(value):\n """A size in whole pixels, with trigonometry\'s rounding error removed.\n\n Rounded before the ceiling. A hexagon of side 40 is exactly 80 across,\n but the cosines that build it give 80.00000000000001, and `ceil` turned\n that into an 81-pixel box with a blank column down one side.\n\n Zero stays zero: `empty_image` is 0x0, and `beside` and friends do\n arithmetic with that. Only an `<svg>` viewport needs a floor of 1, and\n `to_svg` applies it there.\n """\n return int(_math.ceil(round(value, 9)))\n\n\ndef _pll_color_to_css(color):\n """Convert a PLL color value to an SVG/CSS color string.\n\n Accepts what `_pll_check_color` allows: a name or hex string, or an\n (r, g, b) / (r, g, b, a) sequence.\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 # At least 1: an `<svg>` of zero width renders as nothing at all.\n w = max(1, _pll_px(self.width))\n h = max(1, _pll_px(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": _pll_px(self.width),\n "height": _pll_px(self.height),\n "data": self.to_svg(),\n }\n\n def __repr__(self):\n return "<Image %dx%d>" % (\n _pll_px(self.width),\n _pll_px(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# -----------------------------------------------------------------------------\n# Loading a picture from a file or a URL\n# -----------------------------------------------------------------------------\n\ndef _pll_png_size(data):\n if not data.startswith(b"\\x89PNG\\r\\n\\x1a\\n") or data[12:16] != b"IHDR":\n return None\n return (\n int.from_bytes(data[16:20], "big"),\n int.from_bytes(data[20:24], "big"),\n )\n\n\ndef _pll_gif_size(data):\n if not data.startswith((b"GIF87a", b"GIF89a")):\n return None\n return (\n int.from_bytes(data[6:8], "little"),\n int.from_bytes(data[8:10], "little"),\n )\n\n\ndef _pll_jpeg_size(data):\n """Walk the segment chain to the frame header that carries the size."""\n if not data.startswith(b"\\xff\\xd8"):\n return None\n i = 2\n end = len(data)\n while i + 3 < end:\n if data[i] != 0xFF:\n i += 1\n continue\n marker = data[i + 1]\n # Standalone markers: no length field to skip.\n if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7 or marker == 0x01:\n i += 2\n continue\n length = int.from_bytes(data[i + 2:i + 4], "big")\n # Any SOFn except the four that are not frame headers.\n if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC, 0xD8):\n if i + 9 > end:\n return None\n return (\n int.from_bytes(data[i + 7:i + 9], "big"),\n int.from_bytes(data[i + 5:i + 7], "big"),\n )\n i += 2 + max(length, 2)\n return None\n\n\ndef _pll_webp_size(data):\n if not (data.startswith(b"RIFF") and data[8:12] == b"WEBP"):\n return None\n kind = data[12:16]\n if kind == b"VP8 ":\n return (\n int.from_bytes(data[26:28], "little") & 0x3FFF,\n int.from_bytes(data[28:30], "little") & 0x3FFF,\n )\n if kind == b"VP8L":\n bits = int.from_bytes(data[21:25], "little")\n return ((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)\n if kind == b"VP8X":\n return (\n int.from_bytes(data[24:27], "little") + 1,\n int.from_bytes(data[27:30], "little") + 1,\n )\n return None\n\n\ndef _pll_svg_size(data):\n """An SVG\'s size, from `width`/`height` or failing that the viewBox."""\n try:\n head = data[:4096].decode("utf-8", "replace")\n except Exception:\n return None\n if "<svg" not in head:\n return None\n\n def length(name):\n found = _pll_img_re.search(r\'\\b%s\\s*=\\s*["\\\']([0-9.]+)\' % name, head)\n return float(found.group(1)) if found else None\n\n w, h = length("width"), length("height")\n if w and h:\n return (w, h)\n box = _pll_img_re.search(\n r\'\\bviewBox\\s*=\\s*["\\\']\\s*[-0-9.]+[,\\s]+[-0-9.]+[,\\s]+([0-9.]+)[,\\s]+([0-9.]+)\',\n head,\n )\n if box:\n return (float(box.group(1)), float(box.group(2)))\n return None\n\n\n#: Sniffers in the order they are tried, with the media type each implies.\n_PLL_IMAGE_KINDS = (\n ("image/png", _pll_png_size),\n ("image/jpeg", _pll_jpeg_size),\n ("image/gif", _pll_gif_size),\n ("image/webp", _pll_webp_size),\n ("image/svg+xml", _pll_svg_size),\n)\n\n\nclass _LoadedImage(Image):\n """A picture read from a file or a URL, carried as a data URI.\n\n Its bytes are embedded rather than linked, so the picture keeps working\n in a saved SVG, in the interactions panel and in a `.svg` written by\n `--save-images`, none of which can be relied on to fetch anything.\n """\n\n __slots__ = ("_w", "_h", "_href", "_source")\n\n def __init__(self, width, height, media_type, data, source):\n self._w = float(width)\n self._h = float(height)\n encoded = _pll_img_b64.b64encode(data).decode("ascii")\n self._href = "data:%s;base64,%s" % (media_type, encoded)\n self._source = source\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 # `preserveAspectRatio="none"` so a scaled picture fills the box the\n # combinators computed for it, exactly as the drawn shapes do.\n return (\n \'<image x="%g" y="%g" width="%g" height="%g" \'\n \'preserveAspectRatio="none" href="%s" />\'\n ) % (x, y, self._w, self._h, self._href)\n\n def __repr__(self):\n return "<image %gx%g from %s>" % (self._w, self._h, self._source)\n\n\ndef load_image(source):\n """Read a picture from a file beside your program, or from a URL.\n\n Which one is worked out from the text: anything starting `http://` or\n `https://` is fetched, anything else is a file name.\n\n load_image("cat.png")\n load_image("https://example.edu/cat.png")\n\n The result is an ordinary picture, so every combinator works on it -\n `scale`, `rotate`, `beside`, `place_image` and the rest. PNG, JPEG,\n GIF, WebP and SVG are understood.\n """\n data = _pll_read_source(source, "load_image", binary=True)\n if not data:\n raise ValueError("%r is empty, so there is no picture in it." % source)\n for media_type, sniff in _PLL_IMAGE_KINDS:\n size = sniff(data)\n if size is None:\n continue\n width, height = size\n if width <= 0 or height <= 0:\n raise ValueError("%r says it is %gx%g, which is not a picture." % (source, width, height))\n return _LoadedImage(width, height, media_type, data, source)\n raise ValueError(\n "%r is not a picture PLL can read. It understands PNG, JPEG, GIF, "\n "WebP and SVG files." % source\n )\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, _pll_check_mode(mode, "circle"), _pll_check_color(color, "circle"))\n\n\ndef square(side, mode, color):\n """Square with the given side length."""\n return _Rectangle(\n side, side, _pll_check_mode(mode, "square"), _pll_check_color(color, "square")\n )\n\n\ndef rectangle(width, height, mode, color):\n return _Rectangle(\n width, height,\n _pll_check_mode(mode, "rectangle"), _pll_check_color(color, "rectangle"),\n )\n\n\ndef ellipse(width, height, mode, color):\n return _Ellipse(\n width, height,\n _pll_check_mode(mode, "ellipse"), _pll_check_color(color, "ellipse"),\n )\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(\n points, _pll_check_mode(mode, "triangle"), _pll_check_color(color, "triangle")\n )\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 _pll_check_mode(mode, "regular_polygon")\n _pll_check_color(color, "regular_polygon")\n radius = side / (2 * _math.sin(_math.pi / sides))\n # Oriented with a *side* along the bottom, which is what a regular\n # polygon is expected to look like. With an odd number of sides a\n # vertex at the top already gives that - a triangle points up - but\n # with an even number it puts a vertex at the bottom too, so\n # `regular_polygon(40, 4, ...)` came out as a 57x57 diamond instead of\n # a 40x40 square. Half a step of rotation fixes the even cases and\n # leaves the odd ones alone.\n offset = 0.0 if sides % 2 else _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 + offset + 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 # `_Polygon` measures the points it is given, so the box is the shape\'s\n # real extent rather than the circle it was cut from.\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 _pll_check_mode(mode, "star_polygon")\n _pll_check_color(color, "star_polygon")\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, _pll_check_color(color, "line"))\n\n\ndef text(value, size, color):\n return _Text(value, size, _pll_check_color(color, "text"))\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 _pll_px(image.width)\n\n\ndef image_height(image):\n return _pll_px(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 "load_image",\n]\n';
|
|
168
171
|
|
|
169
172
|
// src/common/tableLib.py
|
|
170
|
-
var tableLib_default = `# PLL table library.
|
|
171
|
-
#
|
|
172
|
-
# A small, immutable, Pyret-style table abstraction. Internally a \`Table\`
|
|
173
|
-
# stores its data column-by-column (a dict of name -> list-of-values) so
|
|
174
|
-
# column transforms and aggregations are O(rows) and don't allocate row
|
|
175
|
-
# dicts unless asked. Every operation returns a new \`Table\`; nothing
|
|
176
|
-
# mutates.
|
|
177
|
-
#
|
|
178
|
-
# Public surface (all method-based, so beginners never collide with
|
|
179
|
-
# Python built-ins like \`filter\`, \`sum\`, \`min\`, \`max\`):
|
|
180
|
-
#
|
|
181
|
-
# Construction:
|
|
182
|
-
# table(columns, rows) list-of-rows constructor
|
|
183
|
-
# table_from_columns(d) dict-of-columns constructor
|
|
184
|
-
#
|
|
185
|
-
# Inspection:
|
|
186
|
-
# t.columns() -> list[str]
|
|
187
|
-
# t.length() / len(t) -> int
|
|
188
|
-
# t.column(name) -> list of values
|
|
189
|
-
# t.row(index) -> dict
|
|
190
|
-
# t.rows() -> list[dict]
|
|
191
|
-
#
|
|
192
|
-
# Functional ops (return Table):
|
|
193
|
-
# t.filter(predicate) keep rows where predicate(row) is truthy
|
|
194
|
-
# t.transform_column(name, fn) fn(value) -> new value
|
|
195
|
-
# t.add_column(name, vs_or_fn) vs_or_fn: list of values OR fn(row)
|
|
196
|
-
# t.select_columns(names)
|
|
197
|
-
# t.order_by(name, ascending=True)
|
|
198
|
-
# t.head(n=10)
|
|
199
|
-
# t.tail(n=10)
|
|
200
|
-
#
|
|
201
|
-
# Aggregations (return number):
|
|
202
|
-
# t.sum(name)
|
|
203
|
-
# t.mean(name)
|
|
204
|
-
# t.min(name)
|
|
205
|
-
# t.max(name)
|
|
206
|
-
# t.count() row count (alias for length)
|
|
207
|
-
#
|
|
208
|
-
# Charts (return Image-compatible objects that auto-display):
|
|
209
|
-
# t.bar_chart(x_name, y_name, title=None)
|
|
210
|
-
# t.scatter_chart(x_name, y_name, title=None)
|
|
211
|
-
# t.line_chart(x_name, y_name, title=None)
|
|
212
|
-
# t.histogram(name, bins=10, title=None)
|
|
213
|
-
#
|
|
214
|
-
# Escape hatch:
|
|
215
|
-
# t.to_pandas() -> pandas.DataFrame (lazy import)
|
|
216
|
-
|
|
217
|
-
import math as _math
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
# -----------------------------------------------------------------------------
|
|
221
|
-
# Cell formatting (for HTML display)
|
|
222
|
-
# -----------------------------------------------------------------------------
|
|
223
|
-
|
|
224
|
-
def _format_cell(value):
|
|
225
|
-
"""Render a cell value to a short display string."""
|
|
226
|
-
if value is None:
|
|
227
|
-
return ""
|
|
228
|
-
if isinstance(value, bool):
|
|
229
|
-
return "True" if value else "False"
|
|
230
|
-
if isinstance(value, float):
|
|
231
|
-
if _math.isnan(value):
|
|
232
|
-
return "NaN"
|
|
233
|
-
if _math.isinf(value):
|
|
234
|
-
return "+inf" if value > 0 else "-inf"
|
|
235
|
-
if value == int(value) and abs(value) < 1e16:
|
|
236
|
-
return "%d" % int(value)
|
|
237
|
-
return "%g" % value
|
|
238
|
-
if isinstance(value, int):
|
|
239
|
-
return "%d" % value
|
|
240
|
-
if isinstance(value, str):
|
|
241
|
-
return value
|
|
242
|
-
return repr(value)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
# -----------------------------------------------------------------------------
|
|
246
|
-
# The Table class
|
|
247
|
-
# -----------------------------------------------------------------------------
|
|
248
|
-
|
|
249
|
-
class Table:
|
|
250
|
-
__slots__ = ("_columns", "_data", "_length")
|
|
251
|
-
|
|
252
|
-
def __init__(self, columns, rows):
|
|
253
|
-
"""Build a table from a list of column names and a list of rows.
|
|
254
|
-
|
|
255
|
-
Each row is a list/tuple aligned with \`columns\`. Rows may also be
|
|
256
|
-
dicts; missing keys default to None.
|
|
257
|
-
"""
|
|
258
|
-
cols = list(columns)
|
|
259
|
-
if len(cols) != len(set(cols)):
|
|
260
|
-
raise ValueError("Duplicate column names: %r" % (cols,))
|
|
261
|
-
|
|
262
|
-
data = {c: [] for c in cols}
|
|
263
|
-
n = 0
|
|
264
|
-
for row in rows:
|
|
265
|
-
n += 1
|
|
266
|
-
if isinstance(row, dict):
|
|
267
|
-
for c in cols:
|
|
268
|
-
data[c].append(row.get(c))
|
|
269
|
-
else:
|
|
270
|
-
row_seq = list(row)
|
|
271
|
-
if len(row_seq) != len(cols):
|
|
272
|
-
raise ValueError(
|
|
273
|
-
"Row %d has %d values but the table has %d columns"
|
|
274
|
-
% (n - 1, len(row_seq), len(cols))
|
|
275
|
-
)
|
|
276
|
-
for c, v in zip(cols, row_seq):
|
|
277
|
-
data[c].append(v)
|
|
278
|
-
|
|
279
|
-
self._columns = cols
|
|
280
|
-
self._data = data
|
|
281
|
-
self._length = n
|
|
282
|
-
|
|
283
|
-
# ---- Internal constructor that skips validation ----
|
|
284
|
-
|
|
285
|
-
@classmethod
|
|
286
|
-
def _from_columns(cls, columns, data, length):
|
|
287
|
-
t = cls.__new__(cls)
|
|
288
|
-
t._columns = list(columns)
|
|
289
|
-
t._data = {c: list(data[c]) for c in t._columns}
|
|
290
|
-
t._length = length
|
|
291
|
-
return t
|
|
292
|
-
|
|
293
|
-
# ---- Inspection ----
|
|
294
|
-
|
|
295
|
-
def columns(self):
|
|
296
|
-
"""Names of the table's columns."""
|
|
297
|
-
return list(self._columns)
|
|
298
|
-
|
|
299
|
-
def length(self):
|
|
300
|
-
"""Number of rows in the table."""
|
|
301
|
-
return self._length
|
|
302
|
-
|
|
303
|
-
def __len__(self):
|
|
304
|
-
return self._length
|
|
305
|
-
|
|
306
|
-
def column(self, name):
|
|
307
|
-
"""All values in \`name\` as a list."""
|
|
308
|
-
self._require_column(name)
|
|
309
|
-
return list(self._data[name])
|
|
310
|
-
|
|
311
|
-
def row(self, index):
|
|
312
|
-
"""Row at \`index\` as a {column: value} dict."""
|
|
313
|
-
if index < 0 or index >= self._length:
|
|
314
|
-
raise IndexError(
|
|
315
|
-
"row index %d out of range (table has %d rows)"
|
|
316
|
-
% (index, self._length)
|
|
317
|
-
)
|
|
318
|
-
return {c: self._data[c][index] for c in self._columns}
|
|
319
|
-
|
|
320
|
-
def rows(self):
|
|
321
|
-
"""All rows as a list of {column: value} dicts."""
|
|
322
|
-
return [self.row(i) for i in range(self._length)]
|
|
323
|
-
|
|
324
|
-
# ---- Functional ops ----
|
|
325
|
-
|
|
326
|
-
def filter(self, predicate):
|
|
327
|
-
"""Keep rows where \`predicate(row_dict)\` is truthy."""
|
|
328
|
-
if not callable(predicate):
|
|
329
|
-
raise TypeError("filter expects a function, got %r" % (predicate,))
|
|
330
|
-
keep = []
|
|
331
|
-
for i in range(self._length):
|
|
332
|
-
row = {c: self._data[c][i] for c in self._columns}
|
|
333
|
-
if predicate(row):
|
|
334
|
-
keep.append(i)
|
|
335
|
-
new_data = {c: [self._data[c][i] for i in keep] for c in self._columns}
|
|
336
|
-
return Table._from_columns(self._columns, new_data, len(keep))
|
|
337
|
-
|
|
338
|
-
def transform_column(self, name, fn):
|
|
339
|
-
"""Replace \`name\` with the result of \`fn(value)\` applied to each value."""
|
|
340
|
-
self._require_column(name)
|
|
341
|
-
if not callable(fn):
|
|
342
|
-
raise TypeError("transform_column expects a function, got %r" % (fn,))
|
|
343
|
-
new_data = {c: list(self._data[c]) for c in self._columns}
|
|
344
|
-
new_data[name] = [fn(v) for v in self._data[name]]
|
|
345
|
-
return Table._from_columns(self._columns, new_data, self._length)
|
|
346
|
-
|
|
347
|
-
def add_column(self, name, values_or_fn):
|
|
348
|
-
"""Add a new column.
|
|
349
|
-
|
|
350
|
-
Pass a list of values aligned with the existing rows, or a function
|
|
351
|
-
that takes a row dict and returns the value for that row.
|
|
352
|
-
"""
|
|
353
|
-
if name in self._data:
|
|
354
|
-
raise ValueError("Column %r already exists" % name)
|
|
355
|
-
if callable(values_or_fn):
|
|
356
|
-
new_values = []
|
|
357
|
-
for i in range(self._length):
|
|
358
|
-
row = {c: self._data[c][i] for c in self._columns}
|
|
359
|
-
new_values.append(values_or_fn(row))
|
|
360
|
-
else:
|
|
361
|
-
new_values = list(values_or_fn)
|
|
362
|
-
if len(new_values) != self._length:
|
|
363
|
-
raise ValueError(
|
|
364
|
-
"Column %r has %d values, but the table has %d rows"
|
|
365
|
-
% (name, len(new_values), self._length)
|
|
366
|
-
)
|
|
367
|
-
new_columns = self._columns + [name]
|
|
368
|
-
new_data = {c: list(self._data[c]) for c in self._columns}
|
|
369
|
-
new_data[name] = new_values
|
|
370
|
-
return Table._from_columns(new_columns, new_data, self._length)
|
|
371
|
-
|
|
372
|
-
def select_columns(self, names):
|
|
373
|
-
"""Keep only the columns in \`names\`, in that order."""
|
|
374
|
-
names = list(names)
|
|
375
|
-
for n in names:
|
|
376
|
-
self._require_column(n)
|
|
377
|
-
new_data = {n: list(self._data[n]) for n in names}
|
|
378
|
-
return Table._from_columns(names, new_data, self._length)
|
|
379
|
-
|
|
380
|
-
def order_by(self, name, ascending=True):
|
|
381
|
-
"""Sort rows by \`name\` (ascending by default)."""
|
|
382
|
-
self._require_column(name)
|
|
383
|
-
# Sort indices to keep all columns in lockstep.
|
|
384
|
-
order = sorted(
|
|
385
|
-
range(self._length),
|
|
386
|
-
key=lambda i: _sort_key(self._data[name][i]),
|
|
387
|
-
reverse=not ascending,
|
|
388
|
-
)
|
|
389
|
-
new_data = {c: [self._data[c][i] for i in order] for c in self._columns}
|
|
390
|
-
return Table._from_columns(self._columns, new_data, self._length)
|
|
391
|
-
|
|
392
|
-
def head(self, n=10):
|
|
393
|
-
"""First \`n\` rows as a new table."""
|
|
394
|
-
return self._slice(0, min(n, self._length))
|
|
395
|
-
|
|
396
|
-
def tail(self, n=10):
|
|
397
|
-
"""Last \`n\` rows as a new table."""
|
|
398
|
-
start = max(0, self._length - n)
|
|
399
|
-
return self._slice(start, self._length)
|
|
400
|
-
|
|
401
|
-
def _slice(self, start, end):
|
|
402
|
-
new_data = {c: self._data[c][start:end] for c in self._columns}
|
|
403
|
-
return Table._from_columns(self._columns, new_data, end - start)
|
|
404
|
-
|
|
405
|
-
# ---- Aggregations ----
|
|
406
|
-
|
|
407
|
-
def sum(self, name):
|
|
408
|
-
"""Sum of all values in \`name\` (numeric column)."""
|
|
409
|
-
return _sum_numeric(self._numeric_column(name, "sum"))
|
|
410
|
-
|
|
411
|
-
def mean(self, name):
|
|
412
|
-
"""Mean (average) of \`name\`."""
|
|
413
|
-
values = self._numeric_column(name, "mean")
|
|
414
|
-
if len(values) == 0:
|
|
415
|
-
raise ValueError("mean of empty column %r" % name)
|
|
416
|
-
return _sum_numeric(values) / len(values)
|
|
417
|
-
|
|
418
|
-
def min(self, name):
|
|
419
|
-
"""Minimum value in \`name\`."""
|
|
420
|
-
self._require_column(name)
|
|
421
|
-
values = self._data[name]
|
|
422
|
-
if len(values) == 0:
|
|
423
|
-
raise ValueError("min of empty column %r" % name)
|
|
424
|
-
return min(values, key=_sort_key)
|
|
425
|
-
|
|
426
|
-
def max(self, name):
|
|
427
|
-
"""Maximum value in \`name\`."""
|
|
428
|
-
self._require_column(name)
|
|
429
|
-
values = self._data[name]
|
|
430
|
-
if len(values) == 0:
|
|
431
|
-
raise ValueError("max of empty column %r" % name)
|
|
432
|
-
return max(values, key=_sort_key)
|
|
433
|
-
|
|
434
|
-
def count(self):
|
|
435
|
-
"""Number of rows (alias for length)."""
|
|
436
|
-
return self._length
|
|
437
|
-
|
|
438
|
-
# ---- Charts ----
|
|
439
|
-
|
|
440
|
-
def bar_chart(self, x, y, title=None):
|
|
441
|
-
"""Bar chart: one bar per row, x as label, y as height."""
|
|
442
|
-
self._require_column(x)
|
|
443
|
-
self._require_column(y)
|
|
444
|
-
labels = [_format_cell(v) for v in self._data[x]]
|
|
445
|
-
values = self._numeric_column(y, "bar_chart")
|
|
446
|
-
return _PllChart(_render_bar_chart(labels, values, x, y, title))
|
|
447
|
-
|
|
448
|
-
def scatter_chart(self, x, y, title=None):
|
|
449
|
-
"""Scatter plot of x vs y (both numeric)."""
|
|
450
|
-
xs = self._numeric_column(x, "scatter_chart")
|
|
451
|
-
ys = self._numeric_column(y, "scatter_chart")
|
|
452
|
-
return _PllChart(_render_xy_chart(xs, ys, x, y, title, mode="scatter"))
|
|
453
|
-
|
|
454
|
-
def line_chart(self, x, y, title=None):
|
|
455
|
-
"""Line chart of x vs y (both numeric, sorted by x)."""
|
|
456
|
-
xs = self._numeric_column(x, "line_chart")
|
|
457
|
-
ys = self._numeric_column(y, "line_chart")
|
|
458
|
-
# Sort by x so the line draws monotonically.
|
|
459
|
-
pairs = sorted(zip(xs, ys), key=lambda p: p[0])
|
|
460
|
-
sx = [p[0] for p in pairs]
|
|
461
|
-
sy = [p[1] for p in pairs]
|
|
462
|
-
return _PllChart(_render_xy_chart(sx, sy, x, y, title, mode="line"))
|
|
463
|
-
|
|
464
|
-
def histogram(self, name, bins=10, title=None):
|
|
465
|
-
"""Histogram of \`name\` (numeric)."""
|
|
466
|
-
if bins < 1:
|
|
467
|
-
raise ValueError("bins must be >= 1")
|
|
468
|
-
values = self._numeric_column(name, "histogram")
|
|
469
|
-
return _PllChart(_render_histogram(values, bins, name, title))
|
|
470
|
-
|
|
471
|
-
# ---- Display protocol ----
|
|
472
|
-
|
|
473
|
-
def _pll_table_data(self, max_rows=200):
|
|
474
|
-
"""Return the JSON-friendly payload the host renders."""
|
|
475
|
-
n = self._length
|
|
476
|
-
shown = min(n, max_rows)
|
|
477
|
-
rows = []
|
|
478
|
-
for i in range(shown):
|
|
479
|
-
rows.append([_format_cell(self._data[c][i]) for c in self._columns])
|
|
480
|
-
return {
|
|
481
|
-
"type": "table",
|
|
482
|
-
"columns": list(self._columns),
|
|
483
|
-
"rows": rows,
|
|
484
|
-
"row_count": n,
|
|
485
|
-
"shown_count": shown,
|
|
486
|
-
"truncated": n > shown,
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
# ---- Escape hatch ----
|
|
490
|
-
|
|
491
|
-
def to_pandas(self):
|
|
492
|
-
"""Convert to a pandas DataFrame.
|
|
493
|
-
|
|
494
|
-
Lazy import: pandas is only loaded if you actually call this.
|
|
495
|
-
Inside Pyodide, this triggers a micropip install of pandas the
|
|
496
|
-
first time you use it; subsequent calls reuse the loaded module.
|
|
497
|
-
"""
|
|
498
|
-
import pandas as _pd # noqa: F401 (raises ImportError if unavailable)
|
|
499
|
-
return _pd.DataFrame({c: list(self._data[c]) for c in self._columns})
|
|
500
|
-
|
|
501
|
-
# ---- Repr ----
|
|
502
|
-
|
|
503
|
-
def __repr__(self):
|
|
504
|
-
return "<Table %d rows x %d columns: %s>" % (
|
|
505
|
-
self._length,
|
|
506
|
-
len(self._columns),
|
|
507
|
-
", ".join(self._columns),
|
|
508
|
-
)
|
|
509
|
-
|
|
510
|
-
# ---- Helpers ----
|
|
511
|
-
|
|
512
|
-
def _require_column(self, name):
|
|
513
|
-
if name not in self._data:
|
|
514
|
-
raise KeyError(
|
|
515
|
-
"No column named %r (have: %s)" % (name, ", ".join(self._columns))
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
def _numeric_column(self, name, op):
|
|
519
|
-
self._require_column(name)
|
|
520
|
-
out = []
|
|
521
|
-
for v in self._data[name]:
|
|
522
|
-
f = _to_number(v)
|
|
523
|
-
if f is None:
|
|
524
|
-
raise TypeError(
|
|
525
|
-
"%s needs a numeric column; column %r contains %r"
|
|
526
|
-
% (op, name, v)
|
|
527
|
-
)
|
|
528
|
-
out.append(f)
|
|
529
|
-
return out
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
# -----------------------------------------------------------------------------
|
|
533
|
-
# Constructors (module-level)
|
|
534
|
-
# -----------------------------------------------------------------------------
|
|
535
|
-
|
|
536
|
-
def table(columns, rows):
|
|
537
|
-
"""Build a table from a list of column names and a list of rows."""
|
|
538
|
-
return Table(columns, rows)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def table_from_columns(data):
|
|
542
|
-
"""Build a table from a \`{name: list-of-values}\` dict.
|
|
543
|
-
|
|
544
|
-
Iteration order of the dict determines column order (Python 3.7+
|
|
545
|
-
preserves insertion order).
|
|
546
|
-
"""
|
|
547
|
-
cols = list(data.keys())
|
|
548
|
-
n = None
|
|
549
|
-
for c in cols:
|
|
550
|
-
ln = len(data[c])
|
|
551
|
-
if n is None:
|
|
552
|
-
n = ln
|
|
553
|
-
elif ln != n:
|
|
554
|
-
raise ValueError(
|
|
555
|
-
"Columns have differing lengths: %r"
|
|
556
|
-
% {c: len(data[c]) for c in cols}
|
|
557
|
-
)
|
|
558
|
-
if n is None:
|
|
559
|
-
n = 0
|
|
560
|
-
return Table._from_columns(cols, data, n)
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
# -----------------------------------------------------------------------------
|
|
564
|
-
# Numeric helpers
|
|
565
|
-
# -----------------------------------------------------------------------------
|
|
566
|
-
|
|
567
|
-
def _to_number(value):
|
|
568
|
-
if isinstance(value, bool):
|
|
569
|
-
return float(value)
|
|
570
|
-
if isinstance(value, (int, float)):
|
|
571
|
-
if isinstance(value, float) and (_math.isnan(value) or _math.isinf(value)):
|
|
572
|
-
return None
|
|
573
|
-
return float(value)
|
|
574
|
-
return None
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def _sum_numeric(values):
|
|
578
|
-
s = 0.0
|
|
579
|
-
for v in values:
|
|
580
|
-
s += v
|
|
581
|
-
return s
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def _sort_key(value):
|
|
585
|
-
"""Sort key tolerant of mixed None/str/numeric columns."""
|
|
586
|
-
if value is None:
|
|
587
|
-
return (0, 0)
|
|
588
|
-
if isinstance(value, bool):
|
|
589
|
-
return (1, int(value))
|
|
590
|
-
if isinstance(value, (int, float)):
|
|
591
|
-
return (1, float(value))
|
|
592
|
-
if isinstance(value, str):
|
|
593
|
-
return (2, value)
|
|
594
|
-
return (3, repr(value))
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
# -----------------------------------------------------------------------------
|
|
598
|
-
# Chart rendering
|
|
599
|
-
# -----------------------------------------------------------------------------
|
|
600
|
-
#
|
|
601
|
-
# Charts are pure SVG, produced as a \`_PllChart\` object that exposes the
|
|
602
|
-
# image-display protocol so the host's existing image-card pipeline can
|
|
603
|
-
# render them. We don't subclass \`Image\` from imageLib.py so the table
|
|
604
|
-
# library doesn't need to import it; we duck-type via \`_pll_image_data\`.
|
|
605
|
-
|
|
606
|
-
_CHART_W = 480
|
|
607
|
-
_CHART_H = 320
|
|
608
|
-
_CHART_MARGIN_L = 56
|
|
609
|
-
_CHART_MARGIN_R = 16
|
|
610
|
-
_CHART_MARGIN_T = 28 # space for title
|
|
611
|
-
_CHART_MARGIN_B = 44
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
def _xml_escape(s):
|
|
615
|
-
return (
|
|
616
|
-
str(s)
|
|
617
|
-
.replace("&", "&")
|
|
618
|
-
.replace("<", "<")
|
|
619
|
-
.replace(">", ">")
|
|
620
|
-
.replace('"', """)
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
class _PllChart:
|
|
625
|
-
"""An immutable, displayable SVG chart. Duck-types as a PLL image."""
|
|
626
|
-
|
|
627
|
-
def __init__(self, svg_payload):
|
|
628
|
-
# svg_payload: {"width", "height", "data"}
|
|
629
|
-
self._payload = svg_payload
|
|
630
|
-
|
|
631
|
-
def _pll_image_data(self):
|
|
632
|
-
return {
|
|
633
|
-
"type": "svg",
|
|
634
|
-
"width": int(self._payload["width"]),
|
|
635
|
-
"height": int(self._payload["height"]),
|
|
636
|
-
"data": self._payload["data"],
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
def __repr__(self):
|
|
640
|
-
return "<Chart %dx%d>" % (self._payload["width"], self._payload["height"])
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
def _plot_box():
|
|
644
|
-
"""Inner plotting rectangle: (x, y, w, h)."""
|
|
645
|
-
x = _CHART_MARGIN_L
|
|
646
|
-
y = _CHART_MARGIN_T
|
|
647
|
-
w = _CHART_W - _CHART_MARGIN_L - _CHART_MARGIN_R
|
|
648
|
-
h = _CHART_H - _CHART_MARGIN_T - _CHART_MARGIN_B
|
|
649
|
-
return x, y, w, h
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
def _nice_step(span, target_ticks):
|
|
653
|
-
"""Pick a 1/2/5*10^k step that gives roughly \`target_ticks\` ticks."""
|
|
654
|
-
if span <= 0:
|
|
655
|
-
return 1.0
|
|
656
|
-
raw = span / max(1, target_ticks)
|
|
657
|
-
mag = 10.0 ** _math.floor(_math.log10(raw))
|
|
658
|
-
norm = raw / mag
|
|
659
|
-
if norm < 1.5:
|
|
660
|
-
nice = 1.0
|
|
661
|
-
elif norm < 3.5:
|
|
662
|
-
nice = 2.0
|
|
663
|
-
elif norm < 7.5:
|
|
664
|
-
nice = 5.0
|
|
665
|
-
else:
|
|
666
|
-
nice = 10.0
|
|
667
|
-
return nice * mag
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
def _nice_ticks(lo, hi, target=5):
|
|
671
|
-
if lo == hi:
|
|
672
|
-
# Pad a degenerate range so we still draw something sensible.
|
|
673
|
-
if lo == 0:
|
|
674
|
-
lo, hi = -1.0, 1.0
|
|
675
|
-
else:
|
|
676
|
-
lo, hi = lo - abs(lo) * 0.5, hi + abs(hi) * 0.5
|
|
677
|
-
step = _nice_step(hi - lo, target)
|
|
678
|
-
start = _math.floor(lo / step) * step
|
|
679
|
-
end = _math.ceil(hi / step) * step
|
|
680
|
-
ticks = []
|
|
681
|
-
v = start
|
|
682
|
-
# Guard against floating-point drift on the upper bound.
|
|
683
|
-
while v <= end + step * 0.5:
|
|
684
|
-
ticks.append(v)
|
|
685
|
-
v += step
|
|
686
|
-
return ticks, start, end
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
def _format_tick(v):
|
|
690
|
-
if v == int(v) and abs(v) < 1e16:
|
|
691
|
-
return "%d" % int(v)
|
|
692
|
-
return "%g" % v
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def _chart_frame(title, x_label, y_label, body_svg):
|
|
696
|
-
"""Wrap chart body SVG with title + axis labels + outer <svg>."""
|
|
697
|
-
px, py, pw, ph = _plot_box()
|
|
698
|
-
title_svg = ""
|
|
699
|
-
if title:
|
|
700
|
-
title_svg = (
|
|
701
|
-
'<text x="%d" y="%d" text-anchor="middle" '
|
|
702
|
-
'font-size="13" font-weight="600">%s</text>'
|
|
703
|
-
) % (_CHART_W / 2, _CHART_MARGIN_T - 12, _xml_escape(title))
|
|
704
|
-
|
|
705
|
-
x_label_svg = (
|
|
706
|
-
'<text x="%d" y="%d" text-anchor="middle" '
|
|
707
|
-
'font-size="11" font-style="italic">%s</text>'
|
|
708
|
-
) % (px + pw / 2, _CHART_H - 8, _xml_escape(x_label))
|
|
709
|
-
|
|
710
|
-
# y-label rotated 90deg, anchored on the left margin
|
|
711
|
-
y_label_svg = (
|
|
712
|
-
'<text x="%d" y="%d" text-anchor="middle" font-size="11" '
|
|
713
|
-
'font-style="italic" transform="rotate(-90 %d %d)">%s</text>'
|
|
714
|
-
) % (16, py + ph / 2, 16, py + ph / 2, _xml_escape(y_label))
|
|
715
|
-
|
|
716
|
-
return (
|
|
717
|
-
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
|
|
718
|
-
'viewBox="0 0 %d %d" font-family="sans-serif" '
|
|
719
|
-
'shape-rendering="geometricPrecision">'
|
|
720
|
-
'<rect x="0" y="0" width="%d" height="%d" fill="white"/>'
|
|
721
|
-
"%s%s%s%s</svg>"
|
|
722
|
-
) % (
|
|
723
|
-
_CHART_W,
|
|
724
|
-
_CHART_H,
|
|
725
|
-
_CHART_W,
|
|
726
|
-
_CHART_H,
|
|
727
|
-
_CHART_W,
|
|
728
|
-
_CHART_H,
|
|
729
|
-
title_svg,
|
|
730
|
-
x_label_svg,
|
|
731
|
-
y_label_svg,
|
|
732
|
-
body_svg,
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
def _draw_y_axis(lo, hi, ticks):
|
|
737
|
-
px, py, pw, ph = _plot_box()
|
|
738
|
-
parts = ['<g stroke="#bbb" stroke-width="1" fill="none">']
|
|
739
|
-
text_parts = []
|
|
740
|
-
for t in ticks:
|
|
741
|
-
if t < lo - 1e-9 or t > hi + 1e-9:
|
|
742
|
-
continue
|
|
743
|
-
y = py + ph - (t - lo) / (hi - lo) * ph if hi > lo else py + ph / 2
|
|
744
|
-
parts.append(
|
|
745
|
-
'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>'
|
|
746
|
-
% (px, y, px + pw, y)
|
|
747
|
-
)
|
|
748
|
-
text_parts.append(
|
|
749
|
-
'<text x="%g" y="%g" font-size="10" text-anchor="end" '
|
|
750
|
-
'fill="#444" dominant-baseline="middle">%s</text>'
|
|
751
|
-
% (px - 6, y, _xml_escape(_format_tick(t)))
|
|
752
|
-
)
|
|
753
|
-
# Axis line.
|
|
754
|
-
parts.append('<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/>' % (px, py, px, py + ph))
|
|
755
|
-
parts.append("</g>")
|
|
756
|
-
return "".join(parts) + "".join(text_parts)
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
def _draw_x_axis_numeric(lo, hi, ticks):
|
|
760
|
-
px, py, pw, ph = _plot_box()
|
|
761
|
-
parts = ['<g stroke="#bbb" stroke-width="1" fill="none">']
|
|
762
|
-
text_parts = []
|
|
763
|
-
for t in ticks:
|
|
764
|
-
if t < lo - 1e-9 or t > hi + 1e-9:
|
|
765
|
-
continue
|
|
766
|
-
x = px + (t - lo) / (hi - lo) * pw if hi > lo else px + pw / 2
|
|
767
|
-
parts.append(
|
|
768
|
-
'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>'
|
|
769
|
-
% (x, py, x, py + ph)
|
|
770
|
-
)
|
|
771
|
-
text_parts.append(
|
|
772
|
-
'<text x="%g" y="%g" font-size="10" text-anchor="middle" fill="#444">%s</text>'
|
|
773
|
-
% (x, py + ph + 14, _xml_escape(_format_tick(t)))
|
|
774
|
-
)
|
|
775
|
-
parts.append(
|
|
776
|
-
'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/>'
|
|
777
|
-
% (px, py + ph, px + pw, py + ph)
|
|
778
|
-
)
|
|
779
|
-
parts.append("</g>")
|
|
780
|
-
return "".join(parts) + "".join(text_parts)
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
def _draw_x_axis_categorical(labels):
|
|
784
|
-
px, py, pw, ph = _plot_box()
|
|
785
|
-
parts = ['<g stroke="#888" fill="none">']
|
|
786
|
-
text_parts = []
|
|
787
|
-
n = len(labels)
|
|
788
|
-
if n > 0:
|
|
789
|
-
slot = pw / n
|
|
790
|
-
for i, label in enumerate(labels):
|
|
791
|
-
cx = px + slot * (i + 0.5)
|
|
792
|
-
text_parts.append(
|
|
793
|
-
'<text x="%g" y="%g" font-size="10" text-anchor="middle" '
|
|
794
|
-
'fill="#444">%s</text>' % (cx, py + ph + 14, _xml_escape(label))
|
|
795
|
-
)
|
|
796
|
-
parts.append(
|
|
797
|
-
'<line x1="%g" y1="%g" x2="%g" y2="%g"/>'
|
|
798
|
-
% (px, py + ph, px + pw, py + ph)
|
|
799
|
-
)
|
|
800
|
-
parts.append("</g>")
|
|
801
|
-
return "".join(parts) + "".join(text_parts)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _render_bar_chart(labels, values, x_label, y_label, title):
|
|
805
|
-
px, py, pw, ph = _plot_box()
|
|
806
|
-
n = len(values)
|
|
807
|
-
lo = min(0.0, min(values) if values else 0.0)
|
|
808
|
-
hi = max(values) if values else 1.0
|
|
809
|
-
if hi == lo:
|
|
810
|
-
hi = lo + 1.0
|
|
811
|
-
ticks, lo_n, hi_n = _nice_ticks(lo, hi, target=5)
|
|
812
|
-
|
|
813
|
-
body = []
|
|
814
|
-
body.append(_draw_y_axis(lo_n, hi_n, ticks))
|
|
815
|
-
body.append(_draw_x_axis_categorical(labels))
|
|
816
|
-
|
|
817
|
-
if n > 0:
|
|
818
|
-
slot = pw / n
|
|
819
|
-
bar_w = slot * 0.7
|
|
820
|
-
for i, v in enumerate(values):
|
|
821
|
-
cx = px + slot * (i + 0.5)
|
|
822
|
-
bx = cx - bar_w / 2
|
|
823
|
-
y0 = py + ph - (0 - lo_n) / (hi_n - lo_n) * ph
|
|
824
|
-
yv = py + ph - (v - lo_n) / (hi_n - lo_n) * ph
|
|
825
|
-
top = min(y0, yv)
|
|
826
|
-
h = abs(y0 - yv)
|
|
827
|
-
body.append(
|
|
828
|
-
'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" />'
|
|
829
|
-
% (bx, top, bar_w, h)
|
|
830
|
-
)
|
|
831
|
-
return {
|
|
832
|
-
"width": _CHART_W,
|
|
833
|
-
"height": _CHART_H,
|
|
834
|
-
"data": _chart_frame(title, x_label, y_label, "".join(body)),
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
def _render_xy_chart(xs, ys, x_label, y_label, title, mode):
|
|
839
|
-
px, py, pw, ph = _plot_box()
|
|
840
|
-
if not xs:
|
|
841
|
-
body = ""
|
|
842
|
-
return {
|
|
843
|
-
"width": _CHART_W,
|
|
844
|
-
"height": _CHART_H,
|
|
845
|
-
"data": _chart_frame(title, x_label, y_label, body),
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
x_lo, x_hi = min(xs), max(xs)
|
|
849
|
-
y_lo, y_hi = min(ys), max(ys)
|
|
850
|
-
x_ticks, x_lo_n, x_hi_n = _nice_ticks(x_lo, x_hi, target=6)
|
|
851
|
-
y_ticks, y_lo_n, y_hi_n = _nice_ticks(y_lo, y_hi, target=5)
|
|
852
|
-
|
|
853
|
-
def to_px(x, y):
|
|
854
|
-
sx = px + (x - x_lo_n) / (x_hi_n - x_lo_n) * pw if x_hi_n > x_lo_n else px + pw / 2
|
|
855
|
-
sy = py + ph - (y - y_lo_n) / (y_hi_n - y_lo_n) * ph if y_hi_n > y_lo_n else py + ph / 2
|
|
856
|
-
return sx, sy
|
|
857
|
-
|
|
858
|
-
body = []
|
|
859
|
-
body.append(_draw_y_axis(y_lo_n, y_hi_n, y_ticks))
|
|
860
|
-
body.append(_draw_x_axis_numeric(x_lo_n, x_hi_n, x_ticks))
|
|
861
|
-
|
|
862
|
-
if mode == "line":
|
|
863
|
-
coords = [to_px(x, y) for x, y in zip(xs, ys)]
|
|
864
|
-
path = "M " + " L ".join("%g %g" % (sx, sy) for sx, sy in coords)
|
|
865
|
-
body.append(
|
|
866
|
-
'<path d="%s" fill="none" stroke="#4f8cff" stroke-width="2" '
|
|
867
|
-
'stroke-linejoin="round" stroke-linecap="round"/>' % path
|
|
868
|
-
)
|
|
869
|
-
for sx, sy in coords:
|
|
870
|
-
body.append(
|
|
871
|
-
'<circle cx="%g" cy="%g" r="2.5" fill="#4f8cff"/>' % (sx, sy)
|
|
872
|
-
)
|
|
873
|
-
else:
|
|
874
|
-
for x, y in zip(xs, ys):
|
|
875
|
-
sx, sy = to_px(x, y)
|
|
876
|
-
body.append(
|
|
877
|
-
'<circle cx="%g" cy="%g" r="3" fill="#4f8cff" opacity="0.7"/>'
|
|
878
|
-
% (sx, sy)
|
|
879
|
-
)
|
|
880
|
-
return {
|
|
881
|
-
"width": _CHART_W,
|
|
882
|
-
"height": _CHART_H,
|
|
883
|
-
"data": _chart_frame(title, x_label, y_label, "".join(body)),
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
def _render_histogram(values, bins, name, title):
|
|
888
|
-
px, py, pw, ph = _plot_box()
|
|
889
|
-
if not values:
|
|
890
|
-
body = ""
|
|
891
|
-
return {
|
|
892
|
-
"width": _CHART_W,
|
|
893
|
-
"height": _CHART_H,
|
|
894
|
-
"data": _chart_frame(title, name, "count", body),
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
lo, hi = min(values), max(values)
|
|
898
|
-
if hi == lo:
|
|
899
|
-
hi = lo + 1.0 # avoid zero-width bins for constant data
|
|
900
|
-
width = (hi - lo) / bins
|
|
901
|
-
counts = [0] * bins
|
|
902
|
-
for v in values:
|
|
903
|
-
idx = int((v - lo) / width)
|
|
904
|
-
if idx == bins:
|
|
905
|
-
idx = bins - 1
|
|
906
|
-
counts[idx] += 1
|
|
907
|
-
max_count = max(counts) if counts else 1
|
|
908
|
-
|
|
909
|
-
y_ticks, y_lo_n, y_hi_n = _nice_ticks(0, max_count, target=5)
|
|
910
|
-
x_ticks, x_lo_n, x_hi_n = _nice_ticks(lo, hi, target=6)
|
|
911
|
-
|
|
912
|
-
body = []
|
|
913
|
-
body.append(_draw_y_axis(y_lo_n, y_hi_n, y_ticks))
|
|
914
|
-
body.append(_draw_x_axis_numeric(x_lo_n, x_hi_n, x_ticks))
|
|
915
|
-
|
|
916
|
-
for i, c in enumerate(counts):
|
|
917
|
-
bin_lo = lo + i * width
|
|
918
|
-
bin_hi = bin_lo + width
|
|
919
|
-
sx_lo = px + (bin_lo - x_lo_n) / (x_hi_n - x_lo_n) * pw
|
|
920
|
-
sx_hi = px + (bin_hi - x_lo_n) / (x_hi_n - x_lo_n) * pw
|
|
921
|
-
# Inset slightly so bars don't touch.
|
|
922
|
-
bar_w = max(1.0, sx_hi - sx_lo - 1)
|
|
923
|
-
y_top = py + ph - (c - y_lo_n) / (y_hi_n - y_lo_n) * ph if y_hi_n > y_lo_n else py + ph
|
|
924
|
-
h = py + ph - y_top
|
|
925
|
-
body.append(
|
|
926
|
-
'<rect x="%g" y="%g" width="%g" height="%g" '
|
|
927
|
-
'fill="#4f8cff" opacity="0.85"/>' % (sx_lo + 0.5, y_top, bar_w, h)
|
|
928
|
-
)
|
|
929
|
-
return {
|
|
930
|
-
"width": _CHART_W,
|
|
931
|
-
"height": _CHART_H,
|
|
932
|
-
"data": _chart_frame(title, name, "count", "".join(body)),
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
# -----------------------------------------------------------------------------
|
|
937
|
-
# Names exported into user globals by the bootstrap.
|
|
938
|
-
# -----------------------------------------------------------------------------
|
|
939
|
-
|
|
940
|
-
PLL_TABLE_EXPORTS = [
|
|
941
|
-
"Table",
|
|
942
|
-
"table",
|
|
943
|
-
"table_from_columns",
|
|
944
|
-
]
|
|
945
|
-
`;
|
|
173
|
+
var tableLib_default = '# PLL table library.\n#\n# A small, immutable, Pyret-style table abstraction. Internally a `Table`\n# stores its data column-by-column (a dict of name -> list-of-values) so\n# column transforms and aggregations are O(rows) and don\'t allocate row\n# dicts unless asked. Every operation returns a new `Table`; nothing\n# mutates.\n#\n# Public surface (all method-based, so beginners never collide with\n# Python built-ins like `filter`, `sum`, `min`, `max`):\n#\n# Construction:\n# table(columns, rows) list-of-rows constructor\n# table_from_columns(d) dict-of-columns constructor\n# load_table(source) read a CSV, from a path or a URL.\n# Every cell is text, as in Pyret;\n# convert with transform_column.\n#\n# Inspection:\n# t.columns() -> list[str]\n# t.length() / len(t) -> int\n# t.column(name) -> list of values\n# t.row(index) -> dict\n# t.rows() -> list[dict]\n#\n# Functional ops (return Table):\n# t.filter(predicate) keep rows where predicate(row) is truthy\n# t.transform_column(name, fn) fn(value) -> new value\n# t.add_column(name, vs_or_fn) vs_or_fn: list of values OR fn(row)\n# t.select_columns(names)\n# t.order_by(name, ascending=True)\n# t.head(n=10)\n# t.tail(n=10)\n#\n# Aggregations (return number):\n# t.sum(name)\n# t.mean(name)\n# t.min(name)\n# t.max(name)\n# t.count() row count (alias for length)\n#\n# Charts (return Image-compatible objects that auto-display). The set\n# matches what the Pyret charting library gives a course, so an\n# assignment written against one can be run against the other:\n# t.bar_chart(x_name, y_name) one bar per row\n# t.freq_bar_chart(name) one bar per distinct value\n# t.pie_chart(label_name, value_name)\n# t.scatter_chart(x, y) / t.scatter_plot(x, y)\n# t.labeled_scatter_plot(label_name, x, y)\n# t.line_chart(x, y)\n# t.dot_plot(name) / t.labeled_dot_plot(label_name, name)\n# t.box_plot(name) quartiles, whiskers, outliers\n# t.histogram(name, bins=10, bin_width=None)\n# t.lr_plot(x, y) / t.labeled_lr_plot(label_name, x, y)\n# function_plot(f, x_min, x_max) module-level; needs no table\n# All take an optional `title=`.\n#\n# Statistics behind the charts:\n# t.linear_regression(x, y) -> (slope, intercept, r_squared)\n# Anything else - median, stdev, modes - is Python\'s `statistics`.\n#\n# Equality:\n# t1 == t2 same columns in the same order, same\n# values. `repr` shows the rows, since\n# that is what a failed test prints.\n#\n# Escape hatch:\n# t.to_pandas() -> pandas.DataFrame (lazy import)\n\nimport csv as _csv\nimport io as _io\nimport math as _math\n\n#: Rows `repr(table)` shows before truncating. Enough to see what differs\n#: in a failed comparison, few enough not to bury the rest of the message.\n_REPR_ROWS = 6\n\n\n# -----------------------------------------------------------------------------\n# Cell formatting (for HTML display)\n# -----------------------------------------------------------------------------\n\ndef _format_cell(value):\n """Render a cell value to a short display string."""\n if value is None:\n return ""\n if isinstance(value, bool):\n return "True" if value else "False"\n if isinstance(value, float):\n if _math.isnan(value):\n return "NaN"\n if _math.isinf(value):\n return "+inf" if value > 0 else "-inf"\n if value == int(value) and abs(value) < 1e16:\n return "%d" % int(value)\n return "%g" % value\n if isinstance(value, int):\n return "%d" % value\n if isinstance(value, str):\n return value\n return repr(value)\n\n\n# -----------------------------------------------------------------------------\n# The Table class\n# -----------------------------------------------------------------------------\n\nclass Table:\n __slots__ = ("_columns", "_data", "_length")\n\n def __init__(self, columns, rows):\n """Build a table from a list of column names and a list of rows.\n\n Each row is a list/tuple aligned with `columns`. Rows may also be\n dicts; missing keys default to None.\n """\n cols = list(columns)\n if len(cols) != len(set(cols)):\n raise ValueError("Duplicate column names: %r" % (cols,))\n\n data = {c: [] for c in cols}\n n = 0\n for row in rows:\n n += 1\n if isinstance(row, dict):\n for c in cols:\n data[c].append(row.get(c))\n else:\n row_seq = list(row)\n if len(row_seq) != len(cols):\n raise ValueError(\n "Row %d has %d values but the table has %d columns"\n % (n - 1, len(row_seq), len(cols))\n )\n for c, v in zip(cols, row_seq):\n data[c].append(v)\n\n self._columns = cols\n self._data = data\n self._length = n\n\n # ---- Internal constructor that skips validation ----\n\n @classmethod\n def _from_columns(cls, columns, data, length):\n t = cls.__new__(cls)\n t._columns = list(columns)\n t._data = {c: list(data[c]) for c in t._columns}\n t._length = length\n return t\n\n # ---- Inspection ----\n\n def columns(self):\n """Names of the table\'s columns."""\n return list(self._columns)\n\n def length(self):\n """Number of rows in the table."""\n return self._length\n\n def __len__(self):\n return self._length\n\n def column(self, name):\n """All values in `name` as a list."""\n self._require_column(name)\n return list(self._data[name])\n\n def row(self, index):\n """Row at `index` as a {column: value} dict."""\n if index < 0 or index >= self._length:\n raise IndexError(\n "row index %d out of range (table has %d rows)"\n % (index, self._length)\n )\n return {c: self._data[c][index] for c in self._columns}\n\n def rows(self):\n """All rows as a list of {column: value} dicts."""\n return [self.row(i) for i in range(self._length)]\n\n # ---- Functional ops ----\n\n def filter(self, predicate):\n """Keep rows where `predicate(row_dict)` is truthy."""\n if not callable(predicate):\n raise TypeError("filter expects a function, got %r" % (predicate,))\n keep = []\n for i in range(self._length):\n row = {c: self._data[c][i] for c in self._columns}\n if predicate(row):\n keep.append(i)\n new_data = {c: [self._data[c][i] for i in keep] for c in self._columns}\n return Table._from_columns(self._columns, new_data, len(keep))\n\n def transform_column(self, name, fn):\n """Replace `name` with the result of `fn(value)` applied to each value."""\n self._require_column(name)\n if not callable(fn):\n raise TypeError("transform_column expects a function, got %r" % (fn,))\n new_data = {c: list(self._data[c]) for c in self._columns}\n new_data[name] = [fn(v) for v in self._data[name]]\n return Table._from_columns(self._columns, new_data, self._length)\n\n def add_column(self, name, values_or_fn):\n """Add a new column.\n\n Pass a list of values aligned with the existing rows, or a function\n that takes a row dict and returns the value for that row.\n """\n if name in self._data:\n raise ValueError("Column %r already exists" % name)\n if callable(values_or_fn):\n new_values = []\n for i in range(self._length):\n row = {c: self._data[c][i] for c in self._columns}\n new_values.append(values_or_fn(row))\n else:\n new_values = list(values_or_fn)\n if len(new_values) != self._length:\n raise ValueError(\n "Column %r has %d values, but the table has %d rows"\n % (name, len(new_values), self._length)\n )\n new_columns = self._columns + [name]\n new_data = {c: list(self._data[c]) for c in self._columns}\n new_data[name] = new_values\n return Table._from_columns(new_columns, new_data, self._length)\n\n def select_columns(self, names):\n """Keep only the columns in `names`, in that order."""\n names = list(names)\n for n in names:\n self._require_column(n)\n new_data = {n: list(self._data[n]) for n in names}\n return Table._from_columns(names, new_data, self._length)\n\n def order_by(self, name, ascending=True):\n """Sort rows by `name` (ascending by default)."""\n self._require_column(name)\n # Sort indices to keep all columns in lockstep.\n order = sorted(\n range(self._length),\n key=lambda i: _sort_key(self._data[name][i]),\n reverse=not ascending,\n )\n new_data = {c: [self._data[c][i] for i in order] for c in self._columns}\n return Table._from_columns(self._columns, new_data, self._length)\n\n def head(self, n=10):\n """First `n` rows as a new table."""\n return self._slice(0, min(n, self._length))\n\n def tail(self, n=10):\n """Last `n` rows as a new table."""\n start = max(0, self._length - n)\n return self._slice(start, self._length)\n\n def _slice(self, start, end):\n new_data = {c: self._data[c][start:end] for c in self._columns}\n return Table._from_columns(self._columns, new_data, end - start)\n\n # ---- Aggregations ----\n\n def sum(self, name):\n """Sum of all values in `name` (numeric column)."""\n return _sum_numeric(self._numeric_column(name, "sum"))\n\n def mean(self, name):\n """Mean (average) of `name`."""\n values = self._numeric_column(name, "mean")\n if len(values) == 0:\n raise ValueError("mean of empty column %r" % name)\n return _sum_numeric(values) / len(values)\n\n def min(self, name):\n """Minimum value in `name`."""\n self._require_column(name)\n values = self._data[name]\n if len(values) == 0:\n raise ValueError("min of empty column %r" % name)\n return min(values, key=_sort_key)\n\n def max(self, name):\n """Maximum value in `name`."""\n self._require_column(name)\n values = self._data[name]\n if len(values) == 0:\n raise ValueError("max of empty column %r" % name)\n return max(values, key=_sort_key)\n\n def count(self):\n """Number of rows (alias for length)."""\n return self._length\n\n # ---- Charts ----\n\n def bar_chart(self, x, y, title=None):\n """Bar chart: one bar per row, x as label, y as height."""\n self._require_column(x)\n self._require_column(y)\n labels = [_format_cell(v) for v in self._data[x]]\n values = self._numeric_column(y, "bar_chart")\n return _PllChart(_render_bar_chart(labels, values, x, y, title))\n\n def scatter_chart(self, x, y, title=None):\n """Scatter plot of x vs y (both numeric)."""\n xs = self._numeric_column(x, "scatter_chart")\n ys = self._numeric_column(y, "scatter_chart")\n return _PllChart(_render_xy_chart(xs, ys, x, y, title, mode="scatter"))\n\n def line_chart(self, x, y, title=None):\n """Line chart of x vs y (both numeric, sorted by x)."""\n xs = self._numeric_column(x, "line_chart")\n ys = self._numeric_column(y, "line_chart")\n # Sort by x so the line draws monotonically.\n pairs = sorted(zip(xs, ys), key=lambda p: p[0])\n sx = [p[0] for p in pairs]\n sy = [p[1] for p in pairs]\n return _PllChart(_render_xy_chart(sx, sy, x, y, title, mode="line"))\n\n def histogram(self, name, bins=10, bin_width=None, title=None):\n """Histogram of `name` (numeric).\n\n `bins` counts the buckets; `bin_width` sets how wide each one is\n instead, which is how the same chart is asked for in Pyret and is\n usually what the data calls for ("group ages by 5").\n """\n values = self._numeric_column(name, "histogram")\n if bin_width is not None:\n if bin_width <= 0:\n raise ValueError("bin_width must be greater than 0")\n if not values:\n bins = 1\n else:\n span = max(values) - min(values)\n bins = max(1, int(_math.ceil(span / bin_width))) if span > 0 else 1\n if bins < 1:\n raise ValueError("bins must be >= 1")\n return _PllChart(_render_histogram(values, bins, name, title))\n\n # ---- Charts: the rest of the Pyret set ----\n\n def scatter_plot(self, x, y, title=None):\n """Scatter plot of x vs y. Another name for `scatter_chart`."""\n return self.scatter_chart(x, y, title)\n\n def labeled_scatter_plot(self, labels, x, y, title=None):\n """Scatter plot with the points coloured and keyed by `labels`."""\n self._require_column(labels)\n xs = self._numeric_column(x, "labeled_scatter_plot")\n ys = self._numeric_column(y, "labeled_scatter_plot")\n names = [_format_cell(v) for v in self._data[labels]]\n return _PllChart(\n _render_xy_chart(xs, ys, x, y, title, mode="scatter", labels=names)\n )\n\n def pie_chart(self, labels, values, title=None):\n """Pie chart: one slice per row, sized by `values`."""\n self._require_column(labels)\n amounts = self._numeric_column(values, "pie_chart")\n for amount in amounts:\n if amount < 0:\n raise ValueError(\n "a pie chart cannot show a negative value; column %r "\n "contains %g" % (values, amount)\n )\n names = [_format_cell(v) for v in self._data[labels]]\n return _PllChart(_render_pie_chart(names, amounts, title))\n\n def dot_plot(self, name, title=None):\n """One dot per row along `name`, stacked where rows share a value."""\n values = self._numeric_column(name, "dot_plot")\n if not values:\n raise ValueError("dot_plot needs at least one row")\n return _PllChart(_render_dot_plot(values, None, name, title))\n\n def labeled_dot_plot(self, labels, name, title=None):\n """`dot_plot`, with the dots coloured and keyed by `labels`."""\n self._require_column(labels)\n values = self._numeric_column(name, "labeled_dot_plot")\n if not values:\n raise ValueError("labeled_dot_plot needs at least one row")\n names = [_format_cell(v) for v in self._data[labels]]\n return _PllChart(_render_dot_plot(values, names, name, title))\n\n def freq_bar_chart(self, name, title=None):\n """How often each distinct value of `name` appears.\n\n Unlike `bar_chart` this needs one column, not two: it counts the\n rows itself. The column can hold anything - counting words is the\n usual reason to reach for it.\n """\n self._require_column(name)\n counts = {}\n for value in self._data[name]:\n key = _format_cell(value)\n counts[key] = counts.get(key, 0) + 1\n if not counts:\n raise ValueError("freq_bar_chart needs at least one row")\n keys = sorted(counts, key=_sort_key)\n return _PllChart(\n _render_bar_chart(keys, [float(counts[k]) for k in keys], name, "count", title)\n )\n\n def box_plot(self, name, title=None):\n """Box and whisker plot of `name`: quartiles, range and outliers."""\n values = self._numeric_column(name, "box_plot")\n if not values:\n raise ValueError("box_plot needs at least one row")\n return _PllChart(_render_box_plot(values, name, title))\n\n def lr_plot(self, x, y, title=None):\n """Scatter plot with the line of best fit, and r-squared in the title."""\n xs = self._numeric_column(x, "lr_plot")\n ys = self._numeric_column(y, "lr_plot")\n slope, intercept, r_squared = _pll_linear_fit(xs, ys)\n line = ([min(xs), max(xs)], [slope * min(xs) + intercept, slope * max(xs) + intercept])\n return _PllChart(\n _render_xy_chart(\n xs, ys, x, y,\n title if title is not None else _fit_title(slope, intercept, r_squared),\n mode="scatter", lines=[line],\n )\n )\n\n def labeled_lr_plot(self, labels, x, y, title=None):\n """`lr_plot`, with the points coloured and keyed by `labels`."""\n self._require_column(labels)\n xs = self._numeric_column(x, "labeled_lr_plot")\n ys = self._numeric_column(y, "labeled_lr_plot")\n slope, intercept, r_squared = _pll_linear_fit(xs, ys)\n line = ([min(xs), max(xs)], [slope * min(xs) + intercept, slope * max(xs) + intercept])\n names = [_format_cell(v) for v in self._data[labels]]\n return _PllChart(\n _render_xy_chart(\n xs, ys, x, y,\n title if title is not None else _fit_title(slope, intercept, r_squared),\n mode="scatter", labels=names, lines=[line],\n )\n )\n\n def linear_regression(self, x, y):\n """(slope, intercept, r_squared) for `y` against `x`.\n\n The numbers behind `lr_plot`, for when you want to talk about the\n fit rather than look at it.\n """\n xs = self._numeric_column(x, "linear_regression")\n ys = self._numeric_column(y, "linear_regression")\n return _pll_linear_fit(xs, ys)\n\n # ---- Display protocol ----\n\n def _pll_table_data(self, max_rows=200):\n """Return the JSON-friendly payload the host renders."""\n n = self._length\n shown = min(n, max_rows)\n rows = []\n for i in range(shown):\n rows.append([_format_cell(self._data[c][i]) for c in self._columns])\n return {\n "type": "table",\n "columns": list(self._columns),\n "rows": rows,\n "row_count": n,\n "shown_count": shown,\n "truncated": n > shown,\n }\n\n # ---- Escape hatch ----\n\n def to_pandas(self):\n """Convert to a pandas DataFrame.\n\n Lazy import: pandas is only loaded if you actually call this.\n Inside Pyodide, this triggers a micropip install of pandas the\n first time you use it; subsequent calls reuse the loaded module.\n """\n import pandas as _pd # noqa: F401 (raises ImportError if unavailable)\n return _pd.DataFrame({c: list(self._data[c]) for c in self._columns})\n\n # ---- Equality ----\n\n def __eq__(self, other):\n """Same columns, in the same order, holding the same values.\n\n Column order counts: two tables that display differently are not\n the same table. Cells compare as Python values, so `1 == 1.0` and a\n loaded CSV\'s `29` equals a literal `29`.\n """\n if not isinstance(other, Table):\n return NotImplemented\n if self._columns != other._columns or self._length != other._length:\n return False\n return all(self._data[c] == other._data[c] for c in self._columns)\n\n # Defining `__eq__` makes a class unhashable unless it says otherwise,\n # and that is the right default here: a table\'s cells can be lists or\n # other unhashable values, so there is no honest hash to give.\n __hash__ = None\n\n # ---- Repr ----\n\n def __repr__(self):\n """A readable rendering, because this is what a failed test prints.\n\n The old summary (`<Table 3 rows x 2 columns: name, mpg>`) is the\n same for any two tables of the same shape, which is exactly the\n case `assert t == expected` fails in. Rows are truncated so a big\n table cannot flood a message.\n """\n head = ", ".join(repr(c) for c in self._columns)\n shown = min(self._length, _REPR_ROWS)\n rows = []\n for i in range(shown):\n rows.append("[" + ", ".join(repr(self._data[c][i]) for c in self._columns) + "]")\n body = ", ".join(rows)\n if self._length > shown:\n body += ", ... (%d more rows)" % (self._length - shown)\n return "table([%s], [%s])" % (head, body)\n\n # ---- Helpers ----\n\n def _require_column(self, name):\n if name not in self._data:\n raise KeyError(\n "No column named %r (have: %s)" % (name, ", ".join(self._columns))\n )\n\n def _numeric_column(self, name, op):\n self._require_column(name)\n out = []\n for v in self._data[name]:\n f = _to_number(v)\n if f is None:\n hint = ""\n if isinstance(v, str):\n # By far the most likely cause: the table came from a\n # CSV, where every column is text until converted.\n hint = (\n ". If this came from load_table, convert it first: "\n "t.transform_column(%r, float)" % name\n )\n raise TypeError(\n "%s needs a numeric column; column %r contains %r%s"\n % (op, name, v, hint)\n )\n out.append(f)\n return out\n\n\n# -----------------------------------------------------------------------------\n# Constructors (module-level)\n# -----------------------------------------------------------------------------\n\ndef table(columns, rows):\n """Build a table from a list of column names and a list of rows."""\n return Table(columns, rows)\n\n\ndef table_from_columns(data):\n """Build a table from a `{name: list-of-values}` dict.\n\n Iteration order of the dict determines column order (Python 3.7+\n preserves insertion order).\n """\n cols = list(data.keys())\n n = None\n for c in cols:\n ln = len(data[c])\n if n is None:\n n = ln\n elif ln != n:\n raise ValueError(\n "Columns have differing lengths: %r"\n % {c: len(data[c]) for c in cols}\n )\n if n is None:\n n = 0\n return Table._from_columns(cols, data, n)\n\n\ndef load_table(source):\n """Read a CSV into a table, from a file beside your program or a URL.\n\n Which one is worked out from the text: anything starting `http://` or\n `https://` is fetched, anything else is a file name.\n\n load_table("cars.csv")\n load_table("https://example.edu/cars.csv")\n\n The first row names the columns. **Every cell arrives as text**, the\n way Pyret\'s `load-table` gives it to you, including ones that look like\n numbers; an empty cell is the empty string. Convert a column when you\n want to chart or average it:\n\n cars = load_table("cars.csv").transform_column("mpg", float)\n\n Guessing which columns are numeric reads well in the easy case and\n badly in the rest: a column of years or zip codes becomes arithmetic\n nobody asked for, a stray "n/a" silently turns a numeric column back\n into text, and either way what a table holds depends on the file\n rather than on the program. Converting explicitly is one more line and\n says what it means.\n """\n text = _pll_read_source(source, "load_table")\n # `csv` rather than `split(",")`: quoted fields containing commas and\n # newlines are ordinary in real data, and getting them wrong shifts\n # every later column without saying anything.\n reader = _csv.reader(_io.StringIO(text))\n try:\n rows = [row for row in reader if row and any(cell.strip() for cell in row)]\n except _csv.Error as e:\n raise ValueError("Could not read %r as a CSV: %s" % (source, e)) from None\n if not rows:\n raise ValueError("%r has no rows in it." % source)\n\n header = [name.strip() for name in rows[0]]\n if len(header) != len(set(header)):\n seen = set()\n for name in header:\n if name in seen:\n raise ValueError(\n "%r has two columns called %r. Column names have to be "\n "different, so give one of them another name." % (source, name)\n )\n seen.add(name)\n blank = [i for i, name in enumerate(header) if not name]\n if blank:\n raise ValueError(\n "Column %d of %r has no name in the first row." % (blank[0] + 1, source)\n )\n\n raw = {name: [] for name in header}\n for line_number, row in enumerate(rows[1:], start=2):\n if len(row) != len(header):\n raise ValueError(\n "Line %d of %r has %d value(s) but there are %d columns (%s)."\n % (line_number, source, len(row), len(header), ", ".join(header))\n )\n for name, cell in zip(header, row):\n raw[name].append(cell)\n\n return Table._from_columns(header, raw, len(rows) - 1)\n\n\ndef function_plot(f, x_min, x_max, steps=200, title=None):\n """Plot `f` over the range `x_min` to `x_max`.\n\n The range is required, unlike Pyret\'s `function-plot`, which borrows a\n window from the chart it is drawn into. There is no such window here,\n and guessing one would quietly decide what the picture shows.\n\n function_plot(lambda x: x * x, -3, 3)\n """\n if x_max <= x_min:\n raise ValueError("function_plot needs x_max to be greater than x_min")\n if steps < 2:\n raise ValueError("function_plot needs at least 2 steps")\n xs = []\n ys = []\n for i in range(steps + 1):\n x = x_min + (x_max - x_min) * i / steps\n try:\n y = f(x)\n except Exception as e:\n raise ValueError(\n "function_plot could not work out the value at x=%g (%s: %s)"\n % (x, type(e).__name__, e)\n ) from None\n value = _to_number(y)\n if value is None:\n # A gap - a vertical asymptote, say - rather than a whole\n # failed plot. Drawing what is defined is more use than nothing.\n continue\n xs.append(x)\n ys.append(value)\n if len(xs) < 2:\n raise ValueError(\n "function_plot found no numbers to draw between x=%g and x=%g"\n % (x_min, x_max)\n )\n return _PllChart(\n _render_xy_chart(xs, ys, "x", "y", title, mode="line", markers=False)\n )\n\n\n# -----------------------------------------------------------------------------\n# Numeric helpers\n# -----------------------------------------------------------------------------\n\ndef _to_number(value):\n if isinstance(value, bool):\n return float(value)\n if isinstance(value, (int, float)):\n if isinstance(value, float) and (_math.isnan(value) or _math.isinf(value)):\n return None\n return float(value)\n return None\n\n\ndef _sum_numeric(values):\n s = 0.0\n for v in values:\n s += v\n return s\n\n\ndef _sort_key(value):\n """Sort key tolerant of mixed None/str/numeric columns."""\n if value is None:\n return (0, 0)\n if isinstance(value, bool):\n return (1, int(value))\n if isinstance(value, (int, float)):\n return (1, float(value))\n if isinstance(value, str):\n return (2, value)\n return (3, repr(value))\n\n\n# -----------------------------------------------------------------------------\n# Chart rendering\n# -----------------------------------------------------------------------------\n#\n# Charts are pure SVG, produced as a `_PllChart` object that exposes the\n# image-display protocol so the host\'s existing image-card pipeline can\n# render them. We don\'t subclass `Image` from imageLib.py so the table\n# library doesn\'t need to import it; we duck-type via `_pll_image_data`.\n\n_CHART_W = 480\n_CHART_H = 320\n_CHART_MARGIN_L = 56\n_CHART_MARGIN_R = 16\n_CHART_MARGIN_T = 28 # space for title\n_CHART_MARGIN_B = 44\n\n\ndef _xml_escape(s):\n return (\n str(s)\n .replace("&", "&")\n .replace("<", "<")\n .replace(">", ">")\n .replace(\'"\', """)\n )\n\n\nclass _PllChart:\n """An immutable, displayable SVG chart. Duck-types as a PLL image."""\n\n def __init__(self, svg_payload):\n # svg_payload: {"width", "height", "data"}\n self._payload = svg_payload\n\n def _pll_image_data(self):\n return {\n "type": "svg",\n "width": int(self._payload["width"]),\n "height": int(self._payload["height"]),\n "data": self._payload["data"],\n }\n\n def __repr__(self):\n return "<Chart %dx%d>" % (self._payload["width"], self._payload["height"])\n\n\ndef _plot_box(height=None):\n """Inner plotting rectangle: (x, y, w, h)."""\n x = _CHART_MARGIN_L\n y = _CHART_MARGIN_T\n w = _CHART_W - _CHART_MARGIN_L - _CHART_MARGIN_R\n h = (_CHART_H if height is None else height) - _CHART_MARGIN_T - _CHART_MARGIN_B\n return x, y, w, h\n\n\ndef _nice_step(span, target_ticks):\n """Pick a 1/2/5*10^k step that gives roughly `target_ticks` ticks."""\n if span <= 0:\n return 1.0\n raw = span / max(1, target_ticks)\n mag = 10.0 ** _math.floor(_math.log10(raw))\n norm = raw / mag\n if norm < 1.5:\n nice = 1.0\n elif norm < 3.5:\n nice = 2.0\n elif norm < 7.5:\n nice = 5.0\n else:\n nice = 10.0\n return nice * mag\n\n\ndef _nice_ticks(lo, hi, target=5):\n if lo == hi:\n # Pad a degenerate range so we still draw something sensible.\n if lo == 0:\n lo, hi = -1.0, 1.0\n else:\n lo, hi = lo - abs(lo) * 0.5, hi + abs(hi) * 0.5\n step = _nice_step(hi - lo, target)\n start = _math.floor(lo / step) * step\n end = _math.ceil(hi / step) * step\n ticks = []\n v = start\n # Guard against floating-point drift on the upper bound.\n while v <= end + step * 0.5:\n ticks.append(v)\n v += step\n return ticks, start, end\n\n\ndef _format_tick(v):\n if v == int(v) and abs(v) < 1e16:\n return "%d" % int(v)\n return "%g" % v\n\n\ndef _chart_frame(title, x_label, y_label, body_svg, height=None):\n """Wrap chart body SVG with title + axis labels + outer <svg>."""\n chart_h = _CHART_H if height is None else height\n px, py, pw, ph = _plot_box(height)\n title_svg = ""\n if title:\n title_svg = (\n \'<text x="%d" y="%d" text-anchor="middle" \'\n \'font-size="13" font-weight="600">%s</text>\'\n ) % (_CHART_W / 2, _CHART_MARGIN_T - 12, _xml_escape(title))\n\n x_label_svg = (\n \'<text x="%d" y="%d" text-anchor="middle" \'\n \'font-size="11" font-style="italic">%s</text>\'\n ) % (px + pw / 2, chart_h - 8, _xml_escape(x_label))\n\n # y-label rotated 90deg, anchored on the left margin\n y_label_svg = (\n \'<text x="%d" y="%d" text-anchor="middle" font-size="11" \'\n \'font-style="italic" transform="rotate(-90 %d %d)">%s</text>\'\n ) % (16, py + ph / 2, 16, py + ph / 2, _xml_escape(y_label))\n\n return (\n \'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" \'\n \'viewBox="0 0 %d %d" font-family="sans-serif" \'\n \'shape-rendering="geometricPrecision">\'\n \'<rect x="0" y="0" width="%d" height="%d" fill="white"/>\'\n "%s%s%s%s</svg>"\n ) % (\n _CHART_W,\n chart_h,\n _CHART_W,\n chart_h,\n _CHART_W,\n chart_h,\n title_svg,\n x_label_svg,\n y_label_svg,\n body_svg,\n )\n\n\ndef _draw_y_axis(lo, hi, ticks):\n px, py, pw, ph = _plot_box()\n parts = [\'<g stroke="#bbb" stroke-width="1" fill="none">\']\n text_parts = []\n for t in ticks:\n if t < lo - 1e-9 or t > hi + 1e-9:\n continue\n y = py + ph - (t - lo) / (hi - lo) * ph if hi > lo else py + ph / 2\n parts.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>\'\n % (px, y, px + pw, y)\n )\n text_parts.append(\n \'<text x="%g" y="%g" font-size="10" text-anchor="end" \'\n \'fill="#444" dominant-baseline="middle">%s</text>\'\n % (px - 6, y, _xml_escape(_format_tick(t)))\n )\n # Axis line.\n parts.append(\'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/>\' % (px, py, px, py + ph))\n parts.append("</g>")\n return "".join(parts) + "".join(text_parts)\n\n\ndef _draw_x_axis_numeric(lo, hi, ticks, height=None):\n px, py, pw, ph = _plot_box(height)\n parts = [\'<g stroke="#bbb" stroke-width="1" fill="none">\']\n text_parts = []\n for t in ticks:\n if t < lo - 1e-9 or t > hi + 1e-9:\n continue\n x = px + (t - lo) / (hi - lo) * pw if hi > lo else px + pw / 2\n parts.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>\'\n % (x, py, x, py + ph)\n )\n text_parts.append(\n \'<text x="%g" y="%g" font-size="10" text-anchor="middle" fill="#444">%s</text>\'\n % (x, py + ph + 14, _xml_escape(_format_tick(t)))\n )\n parts.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/>\'\n % (px, py + ph, px + pw, py + ph)\n )\n parts.append("</g>")\n return "".join(parts) + "".join(text_parts)\n\n\ndef _draw_x_axis_categorical(labels):\n px, py, pw, ph = _plot_box()\n parts = [\'<g stroke="#888" fill="none">\']\n text_parts = []\n n = len(labels)\n if n > 0:\n slot = pw / n\n for i, label in enumerate(labels):\n cx = px + slot * (i + 0.5)\n text_parts.append(\n \'<text x="%g" y="%g" font-size="10" text-anchor="middle" \'\n \'fill="#444">%s</text>\' % (cx, py + ph + 14, _xml_escape(label))\n )\n parts.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g"/>\'\n % (px, py + ph, px + pw, py + ph)\n )\n parts.append("</g>")\n return "".join(parts) + "".join(text_parts)\n\n\ndef _render_bar_chart(labels, values, x_label, y_label, title):\n px, py, pw, ph = _plot_box()\n n = len(values)\n lo = min(0.0, min(values) if values else 0.0)\n hi = max(values) if values else 1.0\n if hi == lo:\n hi = lo + 1.0\n ticks, lo_n, hi_n = _nice_ticks(lo, hi, target=5)\n\n body = []\n body.append(_draw_y_axis(lo_n, hi_n, ticks))\n body.append(_draw_x_axis_categorical(labels))\n\n if n > 0:\n slot = pw / n\n bar_w = slot * 0.7\n for i, v in enumerate(values):\n cx = px + slot * (i + 0.5)\n bx = cx - bar_w / 2\n y0 = py + ph - (0 - lo_n) / (hi_n - lo_n) * ph\n yv = py + ph - (v - lo_n) / (hi_n - lo_n) * ph\n top = min(y0, yv)\n h = abs(y0 - yv)\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" />\'\n % (bx, top, bar_w, h)\n )\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _chart_frame(title, x_label, y_label, "".join(body)),\n }\n\n\n#: Series colours, reused for labelled groups and pie slices. Ordered so\n#: neighbouring series stay distinguishable rather than shading into one\n#: another.\n_PALETTE = (\n "#4f8cff", "#f2994a", "#27ae60", "#eb5757",\n "#9b51e0", "#2d9cdb", "#f2c94c", "#56ccf2",\n)\n\n\ndef _palette(i):\n return _PALETTE[i % len(_PALETTE)]\n\n\ndef _plain_frame(title, body_svg, width=_CHART_W, height=_CHART_H):\n """An outer <svg> with a title and no axes, for charts that have none."""\n title_svg = ""\n if title:\n title_svg = (\n \'<text x="%d" y="%d" text-anchor="middle" \'\n \'font-size="13" font-weight="600">%s</text>\'\n ) % (width / 2, _CHART_MARGIN_T - 12, _xml_escape(title))\n return (\n \'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" \'\n \'viewBox="0 0 %d %d" font-family="sans-serif" \'\n \'shape-rendering="geometricPrecision">\'\n \'<rect x="0" y="0" width="%d" height="%d" fill="white"/>\'\n "%s%s</svg>"\n ) % (width, height, width, height, width, height, title_svg, body_svg)\n\n\ndef _legend_width(entries):\n """How wide the key needs to be: swatch, gap, text, padding.\n\n A shared estimate, because the caller positions the panel and the\n panel draws itself - and when the two disagreed by a few pixels the\n last letter of a short label was clipped off the edge of the chart.\n """\n longest = max(len(str(label)) for _, label in entries)\n return 24.0 + longest * 6.2\n\n\ndef _render_legend(entries, x, y):\n """A colour/label key, on a backing panel so it stays readable on data."""\n if not entries:\n return ""\n rows = []\n box_w = _legend_width(entries)\n box_h = 6 + len(entries) * 14\n rows.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="white" \'\n \'fill-opacity="0.85" stroke="#ddd"/>\' % (x, y, box_w, box_h)\n )\n for i, (color, label) in enumerate(entries):\n cy = y + 10 + i * 14\n rows.append(\'<rect x="%g" y="%g" width="8" height="8" fill="%s"/>\'\n % (x + 5, cy - 6, color))\n rows.append(\n \'<text x="%g" y="%g" font-size="10" fill="#333">%s</text>\'\n % (x + 18, cy + 1, _xml_escape(label))\n )\n return "".join(rows)\n\n\ndef _pll_linear_fit(xs, ys):\n """Least-squares fit. Returns (slope, intercept, r_squared)."""\n n = len(xs)\n if n < 2:\n raise ValueError("a line of best fit needs at least two rows")\n mean_x = _sum_numeric(xs) / n\n mean_y = _sum_numeric(ys) / n\n sxx = _sum_numeric([(x - mean_x) ** 2 for x in xs])\n sxy = _sum_numeric([(x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)])\n if sxx == 0:\n raise ValueError(\n "a line of best fit needs the x values to vary; they are all %g"\n % mean_x\n )\n slope = sxy / sxx\n intercept = mean_y - slope * mean_x\n ss_tot = _sum_numeric([(y - mean_y) ** 2 for y in ys])\n ss_res = _sum_numeric([(y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys)])\n # All y equal: the line goes through every point, so it explains\n # everything there is to explain.\n r_squared = 1.0 if ss_tot == 0 else 1.0 - ss_res / ss_tot\n return slope, intercept, r_squared\n\n\ndef _fit_title(slope, intercept, r_squared):\n """Pyret\'s wording, so a course can use either and read the same thing."""\n sign = "+" if intercept >= 0 else "-"\n return "y=%.3fx %s %.3f; r-sq: %.3f" % (\n slope, sign, abs(intercept), r_squared,\n )\n\n\ndef _quartiles(values):\n """(q1, median, q3) by linear interpolation.\n\n The same rule as `statistics.quantiles(..., method="inclusive")` and\n numpy\'s default percentile, so a student can check the box by hand\n against what those report.\n """\n ordered = sorted(values)\n n = len(ordered)\n\n def at(fraction):\n if n == 1:\n return ordered[0]\n pos = fraction * (n - 1)\n low = int(_math.floor(pos))\n high = min(low + 1, n - 1)\n return ordered[low] + (ordered[high] - ordered[low]) * (pos - low)\n\n return at(0.25), at(0.5), at(0.75)\n\n\ndef _render_xy_chart(xs, ys, x_label, y_label, title, mode, labels=None, lines=(), markers=True):\n """Points, optionally grouped by label and overlaid with fitted lines.\n\n One renderer for scatter, line, labelled scatter and the regression\n plots: they differ only in how points are coloured and whether a line\n is drawn through them, so the axis and tick work is not worth\n duplicating four times.\n """\n px, py, pw, ph = _plot_box()\n if not xs:\n body = ""\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _chart_frame(title, x_label, y_label, body),\n }\n\n # A fitted line can reach past the points, so the axes have to cover it\n # too or it would be clipped at the edge of the box.\n all_x = list(xs) + [x for line in lines for x in line[0]]\n all_y = list(ys) + [y for line in lines for y in line[1]]\n x_lo, x_hi = min(all_x), max(all_x)\n y_lo, y_hi = min(all_y), max(all_y)\n x_ticks, x_lo_n, x_hi_n = _nice_ticks(x_lo, x_hi, target=6)\n y_ticks, y_lo_n, y_hi_n = _nice_ticks(y_lo, y_hi, target=5)\n\n def to_px(x, y):\n sx = px + (x - x_lo_n) / (x_hi_n - x_lo_n) * pw if x_hi_n > x_lo_n else px + pw / 2\n sy = py + ph - (y - y_lo_n) / (y_hi_n - y_lo_n) * ph if y_hi_n > y_lo_n else py + ph / 2\n return sx, sy\n\n body = []\n body.append(_draw_y_axis(y_lo_n, y_hi_n, y_ticks))\n body.append(_draw_x_axis_numeric(x_lo_n, x_hi_n, x_ticks))\n\n if mode == "line":\n coords = [to_px(x, y) for x, y in zip(xs, ys)]\n path = "M " + " L ".join("%g %g" % (sx, sy) for sx, sy in coords)\n body.append(\n \'<path d="%s" fill="none" stroke="#4f8cff" stroke-width="2" \'\n \'stroke-linejoin="round" stroke-linecap="round"/>\' % path\n )\n if markers:\n for sx, sy in coords:\n body.append(\n \'<circle cx="%g" cy="%g" r="2.5" fill="#4f8cff"/>\' % (sx, sy)\n )\n elif labels is None:\n for x, y in zip(xs, ys):\n sx, sy = to_px(x, y)\n body.append(\n \'<circle cx="%g" cy="%g" r="3" fill="#4f8cff" opacity="0.7"/>\'\n % (sx, sy)\n )\n else:\n # One colour per distinct label, in the order they first appear, so\n # the key reads in the same order as the table.\n order = []\n for label in labels:\n if label not in order:\n order.append(label)\n colors = {label: _palette(i) for i, label in enumerate(order)}\n for x, y, label in zip(xs, ys, labels):\n sx, sy = to_px(x, y)\n body.append(\n \'<circle cx="%g" cy="%g" r="3.5" fill="%s" opacity="0.85"/>\'\n % (sx, sy, colors[label])\n )\n legend = [(colors[label], label) for label in order]\n # Above a handful of series a key is a wall of text; the colours\n # still separate the groups.\n if len(legend) <= 8:\n body.append(\n _render_legend(legend, px + pw - _legend_width(legend) - 4, py + 4)\n )\n\n for line_xs, line_ys in lines:\n coords = [to_px(x, y) for x, y in zip(line_xs, line_ys)]\n if len(coords) < 2:\n continue\n body.append(\n \'<path d="%s" fill="none" stroke="#eb5757" stroke-width="2"/>\'\n % ("M " + " L ".join("%g %g" % (sx, sy) for sx, sy in coords))\n )\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _chart_frame(title, x_label, y_label, "".join(body)),\n }\n\n\ndef _render_pie_chart(labels, values, title):\n """Slices plus a key, with each slice\'s share of the total."""\n total = _sum_numeric(values)\n if total <= 0:\n raise ValueError(\n "a pie chart needs the values to add up to more than zero; they "\n "add up to %g" % total\n )\n cx, cy = _CHART_W * 0.32, _CHART_MARGIN_T + 124\n radius = 108\n body = []\n legend = []\n angle = -_math.pi / 2 # start at twelve o\'clock\n for i, (label, value) in enumerate(zip(labels, values)):\n if value <= 0:\n continue\n share = value / total\n sweep = share * 2 * _math.pi\n end = angle + sweep\n color = _palette(i)\n if share >= 0.999:\n # A single slice is a whole circle: an arc from a point back to\n # itself draws nothing at all.\n body.append(\'<circle cx="%g" cy="%g" r="%g" fill="%s"/>\' % (cx, cy, radius, color))\n else:\n x1, y1 = cx + radius * _math.cos(angle), cy + radius * _math.sin(angle)\n x2, y2 = cx + radius * _math.cos(end), cy + radius * _math.sin(end)\n body.append(\n \'<path d="M %g %g L %g %g A %g %g 0 %d 1 %g %g Z" fill="%s" \'\n \'stroke="white" stroke-width="1"/>\'\n % (cx, cy, x1, y1, radius, radius, 1 if sweep > _math.pi else 0, x2, y2, color)\n )\n legend.append((color, "%s (%.1f%%)" % (label, share * 100)))\n angle = end\n body.append(_render_legend(legend, _CHART_W * 0.62, _CHART_MARGIN_T + 4))\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _plain_frame(title, "".join(body)),\n }\n\n\n#: A box plot has one row of data, so it gets a strip like the dot plot\n#: rather than a square with the box stranded in the middle of it.\n_BOX_PLOT_H = _CHART_MARGIN_T + 96 + _CHART_MARGIN_B\n\n\ndef _render_box_plot(values, name, title):\n """A box from the quartiles, whiskers to 1.5*IQR, outliers as points."""\n px, py, pw, ph = _plot_box(_BOX_PLOT_H)\n q1, median, q3 = _quartiles(values)\n iqr = q3 - q1\n inside = [v for v in values if q1 - 1.5 * iqr <= v <= q3 + 1.5 * iqr]\n low = min(inside) if inside else min(values)\n high = max(inside) if inside else max(values)\n outliers = [v for v in values if v < low or v > high]\n\n ticks, lo_n, hi_n = _nice_ticks(min(values), max(values), target=6)\n\n def to_x(v):\n if hi_n <= lo_n:\n return px + pw / 2\n return px + (v - lo_n) / (hi_n - lo_n) * pw\n\n mid = py + ph * 0.58\n half = ph * 0.26\n body = [_draw_x_axis_numeric(lo_n, hi_n, ticks, _BOX_PLOT_H)]\n body.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#444"/>\'\n % (to_x(low), mid, to_x(high), mid)\n )\n for v in (low, high):\n body.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#444"/>\'\n % (to_x(v), mid - half * 0.5, to_x(v), mid + half * 0.5)\n )\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" \'\n \'fill-opacity="0.35" stroke="#4f8cff"/>\'\n % (to_x(q1), mid - half, max(to_x(q3) - to_x(q1), 1.0), half * 2)\n )\n body.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#1b4fa0" stroke-width="2"/>\'\n % (to_x(median), mid - half, to_x(median), mid + half)\n )\n for v in outliers:\n body.append(\n \'<circle cx="%g" cy="%g" r="3" fill="none" stroke="#eb5757"/>\'\n % (to_x(v), mid)\n )\n summary = "min %s q1 %s median %s q3 %s max %s" % tuple(\n _format_tick(v) for v in (low, q1, median, q3, high)\n )\n body.append(\n \'<text x="%g" y="%g" font-size="10" fill="#555" text-anchor="middle">%s</text>\'\n % (px + pw / 2, py + 10, _xml_escape(summary))\n )\n return {\n "width": _CHART_W,\n "height": _BOX_PLOT_H,\n "data": _chart_frame(title, name, "", "".join(body), _BOX_PLOT_H),\n }\n\n\n_DOT_RADIUS = 5.0\n\n\ndef _render_dot_plot(values, labels, name, title):\n """One dot per row, stacked where rows share a value.\n\n Sized to the tallest stack rather than to `_CHART_H`, and with no\n y-axis. Stacking says "two rows had this value", which is not a\n quantity to put a scale against - and a full-height square left the\n dots marooned on the bottom axis under an empty y-axis labelled\n "count".\n """\n tallest = 1\n tally = {}\n for v in values:\n tally[v] = tally.get(v, 0) + 1\n tallest = max(tallest, tally[v])\n stack_h = tallest * (_DOT_RADIUS * 2 + 1) + _DOT_RADIUS\n # The key sits inside the plot area, so the strip has to be tall enough\n # to hold it - otherwise a tall stack at the right-hand end draws\n # straight through it.\n keys = len({label for label in labels}) if labels is not None else 0\n legend_h = (6 + keys * 14 + 8) if 0 < keys <= 8 else 0\n chart_h = (\n _CHART_MARGIN_T\n + max(64.0, stack_h + 10, legend_h + stack_h)\n + _CHART_MARGIN_B\n )\n px, py, pw, ph = _plot_box(chart_h)\n ticks, lo_n, hi_n = _nice_ticks(min(values), max(values), target=6)\n\n def to_x(v):\n if hi_n <= lo_n:\n return px + pw / 2\n return px + (v - lo_n) / (hi_n - lo_n) * pw\n\n order = []\n if labels is not None:\n for label in labels:\n if label not in order:\n order.append(label)\n colors = {label: _palette(i) for i, label in enumerate(order)}\n\n body = [_draw_x_axis_numeric(lo_n, hi_n, ticks, chart_h)]\n seen = {}\n for i, v in enumerate(values):\n level = seen.get(v, 0)\n seen[v] = level + 1\n cy = py + ph - _DOT_RADIUS - level * (_DOT_RADIUS * 2 + 1)\n fill = colors[labels[i]] if labels is not None else "#4f8cff"\n body.append(\n \'<circle cx="%g" cy="%g" r="%g" fill="%s" opacity="0.85"/>\'\n % (to_x(v), cy, _DOT_RADIUS, fill)\n )\n if order and len(order) <= 8:\n legend = [(colors[label], label) for label in order]\n body.append(\n _render_legend(legend, px + pw - _legend_width(legend) - 4, py + 2)\n )\n return {\n "width": _CHART_W,\n "height": chart_h,\n "data": _chart_frame(title, name, "", "".join(body), chart_h),\n }\n\n\ndef _render_histogram(values, bins, name, title):\n px, py, pw, ph = _plot_box()\n if not values:\n body = ""\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _chart_frame(title, name, "count", body),\n }\n\n lo, hi = min(values), max(values)\n if hi == lo:\n hi = lo + 1.0 # avoid zero-width bins for constant data\n width = (hi - lo) / bins\n counts = [0] * bins\n for v in values:\n idx = int((v - lo) / width)\n if idx == bins:\n idx = bins - 1\n counts[idx] += 1\n max_count = max(counts) if counts else 1\n\n y_ticks, y_lo_n, y_hi_n = _nice_ticks(0, max_count, target=5)\n x_ticks, x_lo_n, x_hi_n = _nice_ticks(lo, hi, target=6)\n\n body = []\n body.append(_draw_y_axis(y_lo_n, y_hi_n, y_ticks))\n body.append(_draw_x_axis_numeric(x_lo_n, x_hi_n, x_ticks))\n\n for i, c in enumerate(counts):\n bin_lo = lo + i * width\n bin_hi = bin_lo + width\n sx_lo = px + (bin_lo - x_lo_n) / (x_hi_n - x_lo_n) * pw\n sx_hi = px + (bin_hi - x_lo_n) / (x_hi_n - x_lo_n) * pw\n # Inset slightly so bars don\'t touch.\n bar_w = max(1.0, sx_hi - sx_lo - 1)\n y_top = py + ph - (c - y_lo_n) / (y_hi_n - y_lo_n) * ph if y_hi_n > y_lo_n else py + ph\n h = py + ph - y_top\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" \'\n \'fill="#4f8cff" opacity="0.85"/>\' % (sx_lo + 0.5, y_top, bar_w, h)\n )\n return {\n "width": _CHART_W,\n "height": _CHART_H,\n "data": _chart_frame(title, name, "count", "".join(body)),\n }\n\n\n# -----------------------------------------------------------------------------\n# Names exported into user globals by the bootstrap.\n# -----------------------------------------------------------------------------\n\nPLL_TABLE_EXPORTS = [\n "Table",\n "table",\n "table_from_columns",\n "load_table",\n "function_plot",\n]\n';
|
|
946
174
|
|
|
947
175
|
// src/common/reactorLib.py
|
|
948
176
|
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
177
|
|
|
178
|
+
// src/common/examplarLib.py
|
|
179
|
+
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';
|
|
180
|
+
|
|
950
181
|
// src/common/pyodideRunner.ts
|
|
951
182
|
var PYODIDE_BOOTSTRAP_PY = pyodideBootstrap_default;
|
|
952
183
|
var PLL_IMAGE_LIB_PY = imageLib_default;
|
|
953
184
|
var PLL_TABLE_LIB_PY = tableLib_default;
|
|
954
185
|
var PLL_REACTOR_LIB_PY = reactorLib_default;
|
|
186
|
+
var PLL_EXAMPLAR_LIB_PY = examplarLib_default;
|
|
187
|
+
var PANDAS_METHOD_RE = /\.\s*to_pandas\s*\(/;
|
|
955
188
|
var NETWORK_IMPORT_RE = /(^|\n)[ \t]*(?:import|from)[ \t]+(?:pandas|requests|urllib|urllib3|httpx|aiohttp|http)\b/;
|
|
956
189
|
var PYODIDE_HTTP_PATCH_PY = "import pyodide_http as _pll_ph; _pll_ph.patch_all()";
|
|
957
190
|
var PYODIDE_INSTALL_PY = `
|
|
@@ -1121,6 +354,7 @@ function createWorkerHost(adapter) {
|
|
|
1121
354
|
instance.runPython(PLL_IMAGE_LIB_PY);
|
|
1122
355
|
instance.runPython(PLL_TABLE_LIB_PY);
|
|
1123
356
|
instance.runPython(PLL_REACTOR_LIB_PY);
|
|
357
|
+
instance.runPython(PLL_EXAMPLAR_LIB_PY);
|
|
1124
358
|
instance.runPython(PYODIDE_INSTALL_PY);
|
|
1125
359
|
enableTypeChecking(instance);
|
|
1126
360
|
ensureWorkDir(instance.FS);
|
|
@@ -1228,6 +462,9 @@ function createWorkerHost(adapter) {
|
|
|
1228
462
|
}
|
|
1229
463
|
case "loadPackages": {
|
|
1230
464
|
await ready().loadPackagesFromImports(data.code);
|
|
465
|
+
if (PANDAS_METHOD_RE.test(data.code)) {
|
|
466
|
+
await ready().loadPackage("pandas");
|
|
467
|
+
}
|
|
1231
468
|
if (NETWORK_IMPORT_RE.test(data.code)) {
|
|
1232
469
|
await ensureHttpShim();
|
|
1233
470
|
}
|
|
@@ -1288,6 +525,21 @@ function createWorkerHost(adapter) {
|
|
|
1288
525
|
adapter.post({ id: data.id, type: "reactorDisposed" });
|
|
1289
526
|
break;
|
|
1290
527
|
}
|
|
528
|
+
case "examplarBuild": {
|
|
529
|
+
const result = callPython("_pll_examplar_build", [
|
|
530
|
+
data.sources
|
|
531
|
+
]);
|
|
532
|
+
adapter.post({ id: data.id, type: "examplarBuilt", result });
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case "examplarRun": {
|
|
536
|
+
const result = callPython("_pll_examplar_run", [
|
|
537
|
+
data.testSource,
|
|
538
|
+
data.bundle
|
|
539
|
+
]);
|
|
540
|
+
adapter.post({ id: data.id, type: "examplarRan", result });
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
1291
543
|
case "mountWorkspace": {
|
|
1292
544
|
mountWorkspaceFiles(ready().FS, data.files);
|
|
1293
545
|
adapter.post({ id: data.id, type: "workspaceReady" });
|
|
@@ -1361,6 +613,17 @@ fetch(req.url, init).then(async (r) => {
|
|
|
1361
613
|
}
|
|
1362
614
|
|
|
1363
615
|
// src/desktop/xhrPolyfill.ts
|
|
616
|
+
function decodeByteString(bytes) {
|
|
617
|
+
const CHUNK = 32768;
|
|
618
|
+
let out = "";
|
|
619
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
620
|
+
out += String.fromCharCode.apply(
|
|
621
|
+
null,
|
|
622
|
+
bytes.subarray(i, i + CHUNK)
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
1364
627
|
function installNodeXHR() {
|
|
1365
628
|
const g = globalThis;
|
|
1366
629
|
if (typeof g.crossOriginIsolated === "undefined") {
|
|
@@ -1426,7 +689,7 @@ function installNodeXHR() {
|
|
|
1426
689
|
this.response = copy.buffer;
|
|
1427
690
|
this.responseText = "";
|
|
1428
691
|
} else {
|
|
1429
|
-
const text =
|
|
692
|
+
const text = decodeByteString(result.body);
|
|
1430
693
|
this.response = text;
|
|
1431
694
|
this.responseText = text;
|
|
1432
695
|
}
|