pll-python 0.1.2 → 0.4.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.
Files changed (5) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +64 -14
  3. package/cli.cjs +4446 -1074
  4. package/package.json +5 -3
  5. package/worker.cjs +825 -1156
package/worker.cjs CHANGED
@@ -23,44 +23,40 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  ));
24
24
 
25
25
  // src/desktop/pyodideWorker.ts
26
- var import_node_worker_threads = require("node:worker_threads");
26
+ var import_node_worker_threads2 = require("node:worker_threads");
27
27
 
28
28
  // src/common/workspaceFilePolicy.ts
29
29
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
30
30
  var MAX_TOTAL_BYTES = 8 * 1024 * 1024;
31
- var WRITEBACK_EXTENSIONS = /* @__PURE__ */ new Set([
32
- ".csv",
33
- ".txt",
34
- ".tsv",
35
- ".json",
36
- ".md",
37
- ".dat",
38
- ".xml"
39
- ]);
40
- function extensionOf(name) {
41
- const dot = name.lastIndexOf(".");
42
- if (dot <= 0) {
43
- return "";
44
- }
45
- return name.slice(dot).toLowerCase();
31
+ var MAX_FILES = 100;
32
+ var MB = 1024 * 1024;
33
+ var WHY_LEFT_OUT = {
34
+ count: () => `at most ${MAX_FILES} files next to a program are`,
35
+ size: () => `each file can be at most ${MAX_FILE_BYTES / MB} MB`,
36
+ total: () => `the files together can be at most ${MAX_TOTAL_BYTES / MB} MB`,
37
+ unreadable: () => "it could not be read",
38
+ notLoaded: () => "it was not loaded, so saving it would replace a file the program never saw",
39
+ changed: () => "it changed on disk while the program ran",
40
+ python: () => "PLL never overwrites or deletes a .py file",
41
+ hidden: () => "PLL saves nothing into hidden folders",
42
+ unsaved: () => "it has unsaved changes in the editor; save it and run again",
43
+ failed: () => "it could not be written"
44
+ };
45
+ function whyNotLoaded(file) {
46
+ return WHY_LEFT_OUT[file.reason]("loaded");
46
47
  }
47
- function isSafeBasename(name) {
48
- if (!name || name === "." || name === "..") {
48
+ var TOOL_FOLDERS = /* @__PURE__ */ new Set(["__pycache__", "node_modules", "venv", "env", "site-packages"]);
49
+ function isSafePath(name) {
50
+ if (!name || name.includes("\\") || name.includes("\0") || name.startsWith("/")) {
49
51
  return false;
50
52
  }
51
- if (name.startsWith(".")) {
52
- return false;
53
- }
54
- if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
55
- return false;
56
- }
57
- return true;
53
+ return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
58
54
  }
59
- function isWritebackName(name) {
60
- return isSafeBasename(name) && WRITEBACK_EXTENSIONS.has(extensionOf(name));
55
+ function isWalkedFolder(name) {
56
+ return !name.startsWith(".") && !TOOL_FOLDERS.has(name);
61
57
  }
62
- function utf8ByteLength(text) {
63
- return new TextEncoder().encode(text).byteLength;
58
+ function contentsBytes(contents) {
59
+ return typeof contents === "string" ? new TextEncoder().encode(contents) : contents;
64
60
  }
65
61
 
66
62
  // src/common/memfsWorkspace.ts
@@ -73,10 +69,6 @@ function mtimeMs(stat) {
73
69
  }
74
70
  return typeof m === "number" ? m : Number.NaN;
75
71
  }
76
- function joinCwd(FS, name) {
77
- const cwd = FS.cwd();
78
- return cwd.endsWith("/") ? `${cwd}${name}` : `${cwd}/${name}`;
79
- }
80
72
  function ensureWorkDir(FS) {
81
73
  try {
82
74
  FS.mkdir(PLL_WORK_DIR);
@@ -84,918 +76,205 @@ function ensureWorkDir(FS) {
84
76
  }
85
77
  FS.chdir(PLL_WORK_DIR);
86
78
  }
87
- function listRegularFiles(FS) {
79
+ function walk(FS, dir, prefix = "") {
88
80
  const names = [];
89
- for (const name of FS.readdir(FS.cwd())) {
90
- if (name === "." || name === "..") {
81
+ for (const name of FS.readdir(dir)) {
82
+ if (name === "." || name === "..") continue;
83
+ const path = `${dir}/${name}`;
84
+ let stat;
85
+ try {
86
+ stat = FS.stat(path);
87
+ } catch {
91
88
  continue;
92
89
  }
90
+ if (FS.isDir(stat.mode)) {
91
+ if (isWalkedFolder(name)) names.push(...walk(FS, path, `${prefix}${name}/`));
92
+ } else if (FS.isFile(stat.mode)) {
93
+ names.push(`${prefix}${name}`);
94
+ }
95
+ }
96
+ return names;
97
+ }
98
+ function clearWorkDir(FS, dir = PLL_WORK_DIR) {
99
+ for (const name of FS.readdir(dir)) {
100
+ if (name === "." || name === "..") continue;
101
+ const path = `${dir}/${name}`;
93
102
  try {
94
- const stat = FS.stat(joinCwd(FS, name));
95
- if (FS.isFile(stat.mode)) {
96
- names.push(name);
103
+ if (FS.isDir(FS.stat(path).mode)) {
104
+ clearWorkDir(FS, path);
105
+ FS.rmdir(path);
106
+ } else {
107
+ FS.unlink(path);
97
108
  }
98
109
  } catch {
99
110
  }
100
111
  }
101
- return names;
102
112
  }
103
- function clearWorkDirFiles(FS) {
104
- for (const name of listRegularFiles(FS)) {
113
+ function makeFolders(FS, name) {
114
+ const parts = name.split("/").slice(0, -1);
115
+ let at = PLL_WORK_DIR;
116
+ for (const part of parts) {
117
+ at = `${at}/${part}`;
105
118
  try {
106
- FS.unlink(joinCwd(FS, name));
119
+ FS.mkdir(at);
107
120
  } catch {
108
121
  }
109
122
  }
110
123
  }
124
+ function sameBytes(a, b) {
125
+ if (a.byteLength !== b.byteLength) return false;
126
+ for (let i = 0; i < a.byteLength; i++) {
127
+ if (a[i] !== b[i]) return false;
128
+ }
129
+ return true;
130
+ }
111
131
  function mountWorkspaceFiles(FS, files) {
112
132
  ensureWorkDir(FS);
113
- clearWorkDirFiles(FS);
133
+ clearWorkDir(FS);
114
134
  lastMounted = /* @__PURE__ */ new Map();
115
135
  for (const file of files) {
116
- if (!isSafeBasename(file.name)) {
117
- continue;
118
- }
119
- if (utf8ByteLength(file.contents) > MAX_FILE_BYTES) {
136
+ if (!isSafePath(file.name)) {
120
137
  continue;
121
138
  }
122
- const path = joinCwd(FS, file.name);
123
- FS.writeFile(path, file.contents);
139
+ makeFolders(FS, file.name);
140
+ const path = `${PLL_WORK_DIR}/${file.name}`;
141
+ const bytes = contentsBytes(file.contents);
142
+ FS.writeFile(path, bytes);
124
143
  try {
125
144
  FS.utime(path, 0, 0);
126
145
  } catch {
127
146
  }
128
- lastMounted.set(file.name, {
129
- contents: file.contents,
130
- mtimeMs: mtimeMs(FS.stat(path))
131
- });
147
+ lastMounted.set(file.name, { bytes, mtimeMs: mtimeMs(FS.stat(path)) });
132
148
  }
133
149
  }
134
150
  function collectChangedWorkspaceFiles(FS) {
135
151
  ensureWorkDir(FS);
136
- const out = [];
137
- for (const name of listRegularFiles(FS)) {
138
- if (!isWritebackName(name)) {
139
- continue;
140
- }
141
- const path = joinCwd(FS, name);
142
- let contents;
152
+ const files = [];
153
+ const present = new Set(walk(FS, PLL_WORK_DIR));
154
+ for (const name of present) {
155
+ if (name.endsWith(".pyc")) continue;
156
+ const path = `${PLL_WORK_DIR}/${name}`;
157
+ let bytes;
143
158
  let stat;
144
159
  try {
145
- const raw = FS.readFile(path, { encoding: "utf8" });
146
- contents = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
160
+ bytes = FS.readFile(path);
147
161
  stat = FS.stat(path);
148
162
  } catch {
149
163
  continue;
150
164
  }
151
- if (utf8ByteLength(contents) > MAX_FILE_BYTES) {
152
- continue;
153
- }
154
165
  const prev = lastMounted.get(name);
155
- if (prev && prev.contents === contents && prev.mtimeMs === mtimeMs(stat)) {
166
+ if (prev && prev.mtimeMs === mtimeMs(stat) && sameBytes(prev.bytes, bytes)) {
156
167
  continue;
157
168
  }
158
- out.push({ name, contents });
169
+ files.push({ name, contents: bytes });
159
170
  }
160
- return out;
171
+ const deleted = [...lastMounted.keys()].filter((name) => !present.has(name));
172
+ return { files, deleted };
161
173
  }
162
174
 
163
- // src/common/pyodideBootstrap.py
164
- var pyodideBootstrap_default = '# Python Language Levels runtime bootstrap.\n#\n# This module is loaded into Pyodide once when the runtime initializes.\n# It exposes four entry points used by the TypeScript host:\n#\n# _pll_run_file(code, filename, session_key, level="raw") -> dict\n# _pll_repl_eval(code, session_key, level="raw") -> dict\n# _pll_repl_check(source) -> dict\n# _pll_has_tests(code) -> bool\n# _pll_run_tests(code, filename, level="raw") -> dict\n# _pll_static_analyze(code, level, filename, session_key=None) -> list[dict]\n#\n# The level is the only knob: it decides both the static checks and whether\n# annotations are instrumented. `raw` is plain Python plus PLL\'s libraries.\n#\n# Each returns a JSON-friendly dict / list of dicts so the JS side can\n# consume the result via `proxy.toJs({ dict_converter: Object.fromEntries })`.\n#\n# Sessions\n# --------\n# Each Python file gets its own session, keyed by an opaque string the host\n# chooses (typically the document URI). Sessions hold their own globals\n# dict, so file A\'s `data = ...` doesn\'t leak into file B\'s REPL prompt.\n# `_pll_run_file` resets the addressed session\'s globals to the baseline\n# template before executing; `_pll_repl_eval` does NOT reset, so REPL\n# input keeps the names defined by the most recent Run File of the same\n# session.\n\nimport traceback as _tb_mod\nimport ast as _ast\nimport copy as _pll_copy\nimport codeop as _codeop\nimport contextlib\nimport json as _pll_json\nimport sys as _sys\nimport builtins as _builtins_mod\n\n# Must match PLL_WORK_DIR in memfsWorkspace.ts. Sibling files are mounted\n# here and it is cwd, so open("cars.csv") works. It must not sit first on\n# sys.path or a neighboring pandas.py wins over the real package.\n_PLL_WORK_DIR = "/home/pyodide/pll_workspace"\n\n# Vendored pure-Python wheels (typeguard + typing_extensions) that the\n# worker writes into MEMFS before calling `_pll_enable_type_checking`.\n# Must match PLL_VENDOR_DIR in pythonVendor.ts.\n_PLL_VENDOR_DIR = "/pll_vendor"\n\n# Set by `_pll_enable_type_checking`. While False, `_pll_instrument_types`\n# leaves code untouched, so a program still runs - just unchecked.\n_PLL_TYPEGUARD_READY = False\n_pll_typeguard_transformer = None\n_pll_type_check_error = None\n\n# Whether a bool is rejected where int / float is annotated. Set per run\n# from the language level; see `levelRejectsBoolAsNumber` in level.ts.\n# Python counts True as 1 and both mypy and typeguard follow it, which is a\n# hole at the teaching levels: a student who annotates `int` and passes\n# `True` has almost always made a mistake. `advanced` keeps Python\'s rule.\n_PLL_STRICT_NUMBERS = False\n\n# Whether annotations are instrumented at all. Set per run from the language\n# level; see `levelHasTypeChecking` in level.ts. False only at `#level raw`,\n# which exists so a file can opt out. There is no separate setting: the level\n# is the single input, so nothing can disagree with it.\n_PLL_TYPE_CHECK = False\n\n\ndef _pll_check_strict_int(value, origin_type, args, memo):\n """Replaces typeguard\'s `int` check; bools are not whole numbers here.\n\n The wording matches typeguard\'s own so the host-side explainer needs\n no special case: it sees the actual type (bool) and the expected one.\n """\n if isinstance(value, bool) or not isinstance(value, int):\n raise _pll_type_check_error("is not an instance of int")\n\n\ndef _pll_check_strict_float(value, origin_type, args, memo):\n """As `_pll_check_strict_int`, for the int-or-float numeric tower."""\n if isinstance(value, bool) or not isinstance(value, (int, float)):\n raise _pll_type_check_error("is neither float or int")\n\n\ndef _pll_strict_number_lookup(origin_type, args, extras):\n """typeguard checker lookup; consulted on every check, so the flag\n can be flipped per run without re-registering."""\n if not _PLL_STRICT_NUMBERS:\n return None\n if origin_type is int:\n return _pll_check_strict_int\n if origin_type is float:\n return _pll_check_strict_float\n return None\n\n\ndef _pll_apply_level(level):\n """Set the per-run strictness implied by the language level.\n\n The level is the only input. `raw` checks nothing; `advanced` checks\n annotations by Python\'s own rules; the two teaching levels additionally\n reject a bool where a number is annotated. Must agree with level.ts.\n """\n global _PLL_STRICT_NUMBERS, _PLL_TYPE_CHECK\n _PLL_TYPE_CHECK = level != "raw"\n _PLL_STRICT_NUMBERS = level in ("beginner", "intermediate")\n\n\ndef _pll_enable_type_checking():\n """Put the vendored wheels on sys.path and import typeguard.\n\n Returns True when runtime type checking is available. A failure here\n is deliberately not fatal: students\' code must still run, so the\n feature degrades to "no type checks" rather than breaking the editor.\n """\n global _PLL_TYPEGUARD_READY, _pll_typeguard_transformer, _pll_type_check_error\n if _PLL_TYPEGUARD_READY:\n return True\n try:\n for name in ("typing_extensions.whl", "typeguard.whl"):\n path = _PLL_VENDOR_DIR + "/" + name\n # Appended, not prepended: a real package in site-packages\n # (Pyodide ships typing_extensions too) should still win.\n if path not in _sys.path:\n _sys.path.append(path)\n from typeguard import TypeCheckError, TypeCheckMemo\n from typeguard._checkers import checker_lookup_functions\n from typeguard._config import CollectionCheckStrategy, global_config\n from typeguard._functions import check_variable_assignment\n from typeguard._transformer import TypeguardTransformer\n\n # `list[int]` should mean *every* item is an int. typeguard only\n # checks the first item by default, which silently accepts\n # [1, 2, "three"] - confusing when the annotation says otherwise.\n global_config.collection_check_strategy = CollectionCheckStrategy.ALL_ITEMS\n\n # Referenced by name in the AST that `_PllTopLevelAnnAssign`\n # injects, so they have to be visible in user globals.\n _pll_initial_globals["_pll_tg_memo"] = TypeCheckMemo\n _pll_initial_globals["_pll_tg_check_assign"] = check_variable_assignment\n # Consulted before typeguard\'s builtin lookup, and gated on\n # `_PLL_STRICT_NUMBERS` so it is a no-op at `advanced`.\n _pll_type_check_error = TypeCheckError\n if _pll_strict_number_lookup not in checker_lookup_functions:\n checker_lookup_functions.insert(0, _pll_strict_number_lookup)\n\n _pll_typeguard_transformer = TypeguardTransformer\n _PLL_TYPEGUARD_READY = True\n except BaseException:\n _PLL_TYPEGUARD_READY = False\n return _PLL_TYPEGUARD_READY\n\n\ndef _pll_is_vendor_frame(filename):\n return isinstance(filename, str) and filename.startswith(_PLL_VENDOR_DIR)\n\n\ndef _pll_format_exception(exc):\n """`format_exception`, minus frames inside the vendored type checker.\n\n A typeguard failure otherwise ends in several frames of typeguard\'s\n own checker, burying the student\'s line under machinery they did not\n write. When nothing is dropped this returns the stdlib formatting\n unchanged, so ordinary errors look exactly as they did before.\n """\n try:\n frames = _tb_mod.extract_tb(exc.__traceback__)\n kept = [f for f in frames if not _pll_is_vendor_frame(f.filename)]\n if len(kept) == len(frames):\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n parts = ["Traceback (most recent call last):\\n"]\n parts.extend(_tb_mod.StackSummary.from_list(kept).format())\n parts.extend(_tb_mod.format_exception_only(type(exc), exc))\n return "".join(parts)\n except BaseException:\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n\n\ndef _pll_protect_import_path():\n """Keep sibling .py files from shadowing installed packages.\n\n Python puts \'\' (cwd) first on sys.path. After chdir to the work dir,\n import pandas would load a mounted pandas.py. Drop the empty entry,\n append the work dir so unique sibling modules still import, and evict\n anything already loaded from that folder (a failed pandas.py import\n leaves a poisoned sys.modules entry).\n """\n while "" in _sys.path:\n _sys.path.remove("")\n while _PLL_WORK_DIR in _sys.path:\n _sys.path.remove(_PLL_WORK_DIR)\n _sys.path.append(_PLL_WORK_DIR)\n prefix = _PLL_WORK_DIR + "/"\n for name, mod in list(_sys.modules.items()):\n filename = getattr(mod, "__file__", None)\n if isinstance(filename, str) and filename.startswith(prefix):\n _sys.modules.pop(name, None)\n\n# Per-session globals dicts, keyed by session_key (e.g. document URI).\n# Created lazily; initialized from `_pll_initial_globals`.\n_pll_sessions = {}\n\n# The "template" globals used to seed each new session and to reset a\n# session at the start of every Run File. Populated by PYODIDE_INSTALL_PY\n# at the end of bootstrap so the template includes the PLL image\n# library + the auto-display helper.\n_pll_initial_globals = {"__name__": "__main__", "__builtins__": __builtins__}\n\n# Display emissions captured during the most recent `_pll_run_file` /\n# `_pll_repl_eval` call. The host drains this list after the call.\n# Each entry is a typed dict (`{"type": "stdout"|"stderr", "text": ...}`,\n# `{"type": "image", ...}`, or `{"type": "table", ...}`) so the host can\n# dispatch by kind while preserving the *exact* order in which the user\'s\n# code produced them - that way a `print()` followed by a top-level table\n# shows up as text-then-table in the interactions view, even though\n# stdout and image/table emissions take different paths inside Python.\n# Pyodide is single-threaded, so a single shared list is fine.\n_pll_displays = []\n\n# When set (by either host during a file run), this is a JS callback taking\n# one JSON string. Every display payload \u2014 each stdout/stderr write, image,\n# and table \u2014 is emitted through it as it is produced, so the interactions\n# view updates *during* the run instead of only at the end. This is what\n# lets an interactive program print a prompt before input() blocks.\n# Left as None during REPL/tests, where output is delivered in one batch.\n_pll_live_emit = None\n\n\ndef _pll_push(payload):\n """Emit a display payload live, or record it for the end of the run.\n\n Exactly one of the two: when a live hook is installed the host streams\n each payload as it happens and then discards `result["displays"]`\n (see `withLiveEmit` / the `runFile` case in workerHost.ts), so also\n accumulating them costs memory and a large FFI conversion for a list\n nobody reads. A program printing in a loop built a multi-million entry\n list that was copied and converted to JS purely to be dropped.\n """\n emit = _pll_live_emit\n if emit is None:\n _pll_displays.append(payload)\n return\n try:\n emit(_pll_json.dumps(payload))\n except Exception:\n pass\n\n\nclass _PllStream:\n """Drop-in replacement for `sys.stdout` / `sys.stderr` during a run.\n\n Each `write` pushes a typed entry onto `_pll_displays` so text\n output interleaves with image/table emissions. We also keep the\n aggregate string so `result["stdout"]` / `result["stderr"]` can\n still be inspected by smoke tests and any caller that just wants\n "what did the program print?".\n """\n\n __slots__ = ("_kind", "_chunks")\n\n def __init__(self, kind):\n self._kind = kind # "stdout" or "stderr"\n self._chunks = []\n\n def write(self, s):\n if not isinstance(s, str):\n s = str(s)\n if s:\n _pll_push({"type": self._kind, "text": s})\n self._chunks.append(s)\n return len(s)\n\n def writelines(self, lines):\n for line in lines:\n self.write(line)\n\n def flush(self):\n pass\n\n def isatty(self):\n return False\n\n def getvalue(self):\n return "".join(self._chunks)\n\n\ndef _pll_extract_display(value):\n """Try every known display protocol on `value`.\n\n Returns a typed payload (`{"type": "image"|"table", ...}`) or None if\n `value` doesn\'t know how to display itself.\n """\n if hasattr(value, "_pll_table_data"):\n try:\n data = value._pll_table_data()\n except Exception:\n return None\n if isinstance(data, dict):\n data = dict(data)\n data["type"] = "table"\n return data\n if hasattr(value, "_pll_image_data"):\n try:\n data = value._pll_image_data()\n except Exception:\n return None\n # _pll_image_data historically uses {"type": "svg", ...}\n # internally; promote to the unified outer type.\n if isinstance(data, dict):\n payload = dict(data)\n payload["type"] = "image"\n payload["format"] = data.get("type", "svg")\n return payload\n return None\n\n\ndef _pll_show_top_level(value):\n """Emit a value produced by a top-level expression statement.\n\n Mirrors the behavior of Python\'s interactive shell: `None` is suppressed,\n PLL images and tables are captured for the host to render, anything\n else is printed via `repr` so bare expressions like `1 + 2` still display.\n """\n if value is None:\n return\n # A reactor pushed its own card when it was started; printing its repr\n # as well would be noise. Duck-typed like the display protocols, so the\n # bootstrap still knows nothing about reactorLib.\n if getattr(value, "_pll_already_displayed", False):\n return\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n return\n print(repr(value))\n\n\n# Seed the template with the auto-display helper. The image library names\n# get added later by PYODIDE_INSTALL_PY.\n_pll_initial_globals["_pll_show_top_level"] = _pll_show_top_level\n\n\ndef _pll_get_session(session_key):\n """Get-or-create the globals dict for `session_key`.\n\n Newly-created sessions start as a copy of `_pll_initial_globals`\n (so all baseline names like the image primitives are present).\n """\n g = _pll_sessions.get(session_key)\n if g is None:\n g = dict(_pll_initial_globals)\n _pll_sessions[session_key] = g\n return g\n\n\ndef _pll_reset_session(session_key):\n """Reset the globals for `session_key` to the baseline template.\n\n Mutates the existing dict in place (`clear` + `update`) so any cached\n reference to it (e.g. from `_pll_show_top_level`\'s closure or from\n Pyodide\'s `globals.get(...)`) remains valid.\n """\n g = _pll_get_session(session_key)\n g.clear()\n g.update(_pll_initial_globals)\n return g\n\n\nclass _PllTopLevelAnnAssign(_ast.NodeTransformer):\n """Type-check annotated assignments outside functions.\n\n typeguard instruments function arguments, return values, and\n annotated assignments *inside* functions, but leaves\n `total: int = "oops"` at module level unchecked. Students write those,\n so we wrap the value in typeguard\'s own check to get the same wording.\n\n Function and class bodies are skipped: typeguard already covers\n functions, and a bare `name: str` in a class body is a field\n declaration (dataclasses, etc.) rather than an assignment.\n """\n\n def visit_FunctionDef(self, node):\n return node\n\n visit_AsyncFunctionDef = visit_FunctionDef\n\n def visit_ClassDef(self, node):\n return node\n\n def visit_AnnAssign(self, node):\n if node.value is None or not isinstance(node.target, _ast.Name):\n return node\n node.value = self._checked(node.target.id, node.value, node.annotation)\n return node\n\n def _checked(self, name, value, annotation):\n """`value` -> `_pll_tg_check_assign(value, [(name, ann)], memo)`."""\n memo = _ast.Call(\n func=_ast.Name(id="_pll_tg_memo", ctx=_ast.Load()),\n args=[\n _ast.Call(func=_ast.Name(id="globals", ctx=_ast.Load()), args=[], keywords=[]),\n _ast.Call(func=_ast.Name(id="locals", ctx=_ast.Load()), args=[], keywords=[]),\n ],\n keywords=[],\n )\n target = _ast.Tuple(\n elts=[_ast.Constant(value=name), _pll_copy.deepcopy(annotation)],\n ctx=_ast.Load(),\n )\n call = _ast.Call(\n func=_ast.Name(id="_pll_tg_check_assign", ctx=_ast.Load()),\n args=[value, _ast.List(elts=[target], ctx=_ast.Load()), memo],\n keywords=[],\n )\n _ast.copy_location(call, value)\n _ast.fix_missing_locations(call)\n return call\n\n\ndef _pll_parse_and_instrument(code, filename):\n """Parse `code`, adding runtime type checks when they are available.\n\n Whether to check at all comes from `_PLL_TYPE_CHECK`, which\n `_pll_apply_level` sets from the level before this is called.\n\n Instrumentation is attempted on a second parse and validated by\n compiling it, so anything typeguard cannot handle falls back to the\n plain tree rather than failing the run.\n """\n tree = _ast.parse(code, filename=filename, mode="exec")\n if not _PLL_TYPE_CHECK or not _PLL_TYPEGUARD_READY:\n return tree\n try:\n instrumented = _ast.parse(code, filename=filename, mode="exec")\n _pll_typeguard_transformer().visit(instrumented)\n _PllTopLevelAnnAssign().visit(instrumented)\n _ast.fix_missing_locations(instrumented)\n compile(instrumented, filename, "exec")\n return instrumented\n except BaseException:\n return tree\n\n\nclass _PllTopLevelExprWrapper(_ast.NodeTransformer):\n """Wrap module-level expression statements so they auto-display.\n\n Skips the conventional module docstring (a string literal as the first\n statement) and bare `None` / `...` constants which are usually noise.\n """\n\n def visit_Module(self, node):\n new_body = []\n for i, stmt in enumerate(node.body):\n if isinstance(stmt, _ast.Expr) and not _pll_should_skip_expr(stmt, i):\n call = _ast.Call(\n func=_ast.Name(id="_pll_show_top_level", ctx=_ast.Load()),\n args=[stmt.value],\n keywords=[],\n )\n wrapped = _ast.Expr(value=call)\n _ast.copy_location(wrapped, stmt)\n _ast.fix_missing_locations(wrapped)\n new_body.append(wrapped)\n else:\n new_body.append(stmt)\n node.body = new_body\n return node\n\n\ndef _pll_should_skip_expr(stmt, index):\n value = stmt.value\n if isinstance(value, _ast.Constant):\n if index == 0 and isinstance(value.value, str):\n return True # module docstring\n if value.value is None or value.value is Ellipsis:\n return True\n return False\n\n\n# -----------------------------------------------------------------------------\n# REPL syntax check (codeop.compile_command in \'single\' mode)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_check(source):\n """Return whether \'source\' is a complete REPL input.\n\n Mirrors what CPython\'s interactive shell does: uses codeop.compile_command\n in \'single\' mode, which returns None for incomplete input (e.g. open\n parens, unfinished block) and raises SyntaxError for actually-broken code.\n """\n try:\n result = _codeop.compile_command(source, "<repl>", "single")\n except (SyntaxError, OverflowError, ValueError) as e:\n return {\n "status": "invalid",\n "error_type": type(e).__name__,\n "message": str(e),\n "lineno": getattr(e, "lineno", None) or 0,\n "offset": (e.offset - 1) if isinstance(e, SyntaxError) and e.offset else 0,\n }\n if result is None:\n return {"status": "incomplete"}\n return {"status": "complete"}\n\n\n# -----------------------------------------------------------------------------\n# Run a whole file (with output capture and traceback extraction)\n# -----------------------------------------------------------------------------\n\ndef _pll_extract_loc(tb_str, fallback_filename):\n line_no = None\n col = None\n for raw in reversed(tb_str.splitlines()):\n line = raw.strip()\n if line.startswith(\'File "\') and ", line " in line:\n try:\n rest = line.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n line_no = None\n break\n if line_no is None and fallback_filename:\n for raw in tb_str.splitlines():\n if fallback_filename in raw and ", line " in raw:\n try:\n rest = raw.split(", line ", 1)[1]\n num_part = rest.split(",", 1)[0].strip()\n line_no = int(num_part)\n except Exception:\n pass\n break\n return line_no, col\n\n\ndef _pll_run_file(code, filename, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n # Each Run File starts with a clean slate for this session: discard any\n # names defined by a previous Run File of the same session or by REPL\n # exploration since then.\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_reset_session(session_key)\n _pll_displays.clear()\n try:\n tree = _pll_parse_and_instrument(code, filename)\n _PllTopLevelExprWrapper().visit(tree)\n _ast.fix_missing_locations(tree)\n compiled = compile(tree, filename, "exec")\n except SyntaxError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = tb_text\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, user_globals)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# Tests (pytest, same-file test_* / Test* collection)\n# -----------------------------------------------------------------------------\n\ndef _pll_has_tests(code):\n """Return True if `code` looks like it contains pytest tests.\n\n Mirrors pytest\'s default collection: module-level `test_*` functions\n and `test_*` methods on `Test*` classes. Used so we can skip loading\n pytest for files that have no tests.\n """\n try:\n tree = _ast.parse(code)\n except (SyntaxError, ValueError):\n return False\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n return True\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n return True\n return False\n\n\n_PLL_TRACE_SKIP = (\n _PLL_VENDOR_DIR,\n "_pytest",\n "pluggy",\n "site-packages",\n "/lib/python",\n "get_terminal_writer",\n "pyio.py",\n "pathlib",\n)\n\n\ndef _pll_is_internal_frame(line):\n return any(tok in line for tok in _PLL_TRACE_SKIP)\n\n\ndef _pll_fix_ast_ranges(tree):\n """Make rewritten asserts compile on Python 3.12+.\n\n `rewrite_asserts` can leave `end_lineno` < `lineno` on injected nodes.\n CPython then raises `ValueError: AST node line range (...) is not valid`.\n """\n for child in _ast.walk(tree):\n lineno = getattr(child, "lineno", None)\n end_lineno = getattr(child, "end_lineno", None)\n if lineno is not None and end_lineno is not None and end_lineno < lineno:\n child.end_lineno = lineno\n col = getattr(child, "col_offset", None)\n end_col = getattr(child, "end_col_offset", None)\n if (\n col is not None\n and end_col is not None\n and lineno is not None\n and end_lineno is not None\n and lineno == end_lineno\n and end_col < col\n ):\n child.end_col_offset = col\n\n\ndef _pll_test_locations(tree):\n """Map collected test names to 1-based source lines."""\n locs = {}\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n locs[node.name] = node.lineno\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n locs[node.name + "::" + item.name] = item.lineno\n return locs\n\n\ndef _pll_friendly_assert_message(exc, tb_text):\n """Short explanation of a failed assert, with pytest internals stripped."""\n msg = str(exc).strip()\n lines = []\n for raw in msg.split("\\n"):\n stripped = raw.strip()\n if not stripped:\n continue\n if _pll_is_internal_frame(stripped):\n continue\n if stripped.startswith("+"):\n stripped = stripped.lstrip("+ ").strip()\n lines.append(stripped)\n if lines:\n return "\\n".join(lines)\n for raw in reversed((tb_text or "").splitlines()):\n stripped = raw.strip()\n if stripped.startswith("assert "):\n return stripped\n return "This test failed."\n\n\ndef _pll_is_async(fn):\n try:\n import inspect\n return inspect.iscoroutinefunction(fn)\n except Exception:\n return False\n\n\ndef _pll_call_test(fn):\n """Run one test function. Returns (outcome, message, stdout)."""\n import io\n\n buf = io.StringIO()\n try:\n with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):\n if _pll_is_async(fn):\n return (\n "error",\n "async tests are not supported.",\n buf.getvalue().strip() or None,\n )\n fn()\n return ("passed", None, buf.getvalue().strip() or None)\n except AssertionError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n return (\n "failed",\n _pll_friendly_assert_message(e, tb_text),\n buf.getvalue().strip() or None,\n )\n except BaseException as e:\n name = type(e).__name__\n if name == "Skipped":\n return ("skipped", str(e).strip() or None, buf.getvalue().strip() or None)\n return (\n "error",\n name + ": " + (str(e) or "this test raised an exception."),\n buf.getvalue().strip() or None,\n )\n\n\ndef _pll_iter_tests(ns):\n """Yield (name, callable) for pytest-style tests in namespace `ns`."""\n items = list(ns.items())\n for name, obj in items:\n if name.startswith("test_") and callable(obj) and not isinstance(obj, type):\n yield name, obj\n for name, obj in items:\n if not (isinstance(obj, type) and name.startswith("Test")):\n continue\n # pytest skips classes with a custom constructor.\n try:\n obj()\n except TypeError:\n continue\n for meth_name in dir(obj):\n if not meth_name.startswith("test_"):\n continue\n attr = getattr(obj, meth_name, None)\n if not callable(attr):\n continue\n\n def _bound(cls=obj, method=meth_name):\n return getattr(cls(), method)()\n\n yield name + "::" + meth_name, _bound\n\n\ndef _pll_run_tests(code, filename, level="raw"):\n """Run same-file tests (`test_*` / `Test*`) in an isolated namespace.\n\n Uses pytest only to rewrite assertions so failures show `assert 4 == 5`\n instead of an empty AssertionError. Does **not** call `pytest.main()`,\n which is not safe to invoke repeatedly in one Pyodide interpreter.\n """\n display_name = filename.rsplit("/", 1)[-1].rsplit("\\\\", 1)[-1] or "user_script.py"\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "internal_error": False,\n "passed": 0,\n "failed": 0,\n "skipped": 0,\n "errors": 0,\n "tests": [],\n "stdout": "",\n "stderr": "",\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n\n try:\n tree = _pll_parse_and_instrument(code, display_name)\n except SyntaxError as e:\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n locs = _pll_test_locations(tree)\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n rewrite_asserts(tree, code.encode("utf-8"), module_path=display_name)\n # Do not call ast.fix_missing_locations here: it copies parent\n # positions onto pytest\'s injected nodes and yields ranges that\n # Python 3.12+ rejects (`end_lineno` < `lineno`).\n _pll_fix_ast_ranges(tree)\n compiled = compile(tree, display_name, "exec")\n except Exception:\n # Assert rewriting failed; keep the type instrumentation.\n compiled = compile(tree, display_name, "exec")\n\n ns = dict(_pll_initial_globals)\n ns["__name__"] = "__pll_test__"\n ns["__file__"] = display_name\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, ns)\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["internal_error"] = True\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, display_name)\n result["line_number"] = line_no\n result["column"] = col\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n rows = []\n passed = failed = errors = skipped = 0\n for name, fn in _pll_iter_tests(ns):\n outcome, message, cap = _pll_call_test(fn)\n if outcome == "passed":\n passed += 1\n elif outcome == "failed":\n failed += 1\n elif outcome == "skipped":\n skipped += 1\n else:\n errors += 1\n rows.append({\n "name": name,\n "outcome": outcome,\n "line_number": locs.get(name),\n "message": message,\n "stdout": cap,\n })\n\n result["passed"] = passed\n result["failed"] = failed\n result["skipped"] = skipped\n result["errors"] = errors\n result["tests"] = rows\n result["ok"] = failed == 0 and errors == 0\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# -----------------------------------------------------------------------------\n# REPL-style eval (statements + last-expression value)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_eval(code, session_key, level="raw"):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "line_number": None,\n "column": None,\n "displays": [],\n }\n _pll_displays.clear()\n _pll_apply_level(level)\n _pll_protect_import_path()\n user_globals = _pll_get_session(session_key)\n filename = "<repl>"\n try:\n tree = _pll_parse_and_instrument(code, filename)\n except SyntaxError as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n result["line_number"] = e.lineno\n result["column"] = (e.offset - 1) if e.offset else None\n return result\n\n last_expr = None\n if tree.body and isinstance(tree.body[-1], _ast.Expr):\n last_expr = tree.body[-1]\n tree.body = tree.body[:-1]\n\n try:\n with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n if tree.body:\n compiled_stmts = compile(tree, filename, "exec")\n exec(compiled_stmts, user_globals)\n if last_expr is not None:\n expr_module = _ast.Expression(body=last_expr.value)\n compiled_expr = compile(expr_module, filename, "eval")\n value = eval(compiled_expr, user_globals)\n if value is not None:\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n else:\n result["result_repr"] = repr(value)\n result["ok"] = True\n except SystemExit:\n result["ok"] = True\n except BaseException as e:\n formatted = _pll_format_exception(e)\n result["error_type"] = type(e).__name__\n result["error_message"] = str(e)\n result["traceback"] = formatted\n line_no, col = _pll_extract_loc(formatted, filename)\n result["line_number"] = line_no\n result["column"] = col\n finally:\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\n# =============================================================================\n# Static analysis (beginner / intermediate level checks)\n# =============================================================================\n#\n# Per-level rules:\n#\n# beginner:\n# 1. Shadowing: a binding whose name appears in any enclosing\n# scope, is the name of a Python built-in, or is\n# provided by a PLL library (image / table /\n# reactor - the names every session starts with).\n# 2. Reassignment: a name bound more than once within the *same*\n# scope. Suppressed for names already flagged as\n# shadowing in that scope (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# intermediate:\n# 1. Shadowing: same as beginner.\n# 2. Reassignment: only flagged at module scope. Function/lambda/\n# class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator\n# patterns (e.g. `total = 0; for x in xs: total += x`\n# inside `def`).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n#\n# advanced:\n# No checks. Full Python.\n#\n# A "scope" is one of: module, function (incl. async), lambda, class,\n# comprehension/generator. We model these explicitly because Python 3\n# comprehensions have their own scope.\n\n\n_PLL_SCOPE_FUNC = (_ast.FunctionDef, _ast.AsyncFunctionDef)\n_PLL_SCOPE_COMP = (_ast.ListComp, _ast.SetComp, _ast.DictComp, _ast.GeneratorExp)\n\n# Attributes `dir()` reports for the `builtins` *module object* itself. They\n# are not built-in functions, so reporting them as shadowed would be a false\n# positive with misleading wording ("`__name__` is the name of a Python\n# built-in"). Everything else `dir()` returns is a real builtin and is worth\n# flagging, including the dunder ones like `__import__`.\n_PLL_BUILTINS_MODULE_META = frozenset(\n ("__doc__", "__loader__", "__name__", "__package__", "__spec__")\n)\n\n\nclass _PllScope:\n __slots__ = ("node", "kind", "parent", "bindings")\n\n def __init__(self, node, kind, parent):\n self.node = node\n self.kind = kind # "module" | "function" | "lambda" | "class" | "comprehension"\n self.parent = parent # _PllScope | None\n # name -> [(lineno, col, kind, module), ...]. `module` is set only\n # for import bindings: the module the name was imported from.\n self.bindings = {}\n\n\ndef _pll_arg_names(args):\n """All argument names (with positions) on an ast.arguments node."""\n out = []\n posonly = list(getattr(args, "posonlyargs", []) or [])\n for a in posonly + list(args.args) + list(args.kwonlyargs):\n out.append((a.arg, a.lineno, a.col_offset))\n if args.vararg:\n out.append((args.vararg.arg, args.vararg.lineno, args.vararg.col_offset))\n if args.kwarg:\n out.append((args.kwarg.arg, args.kwarg.lineno, args.kwarg.col_offset))\n return out\n\n\nclass _PllScopeBuilder:\n """Walk a module AST and build a list of scopes with their bindings.\n\n A binding is collected in the *enclosing* scope of the syntactic node\n (e.g. a `def f` adds `f` to the surrounding scope and creates a new\n function scope for its body). Comprehensions, lambdas, and classes\n each open their own scope.\n """\n\n def __init__(self):\n self.scopes = []\n\n def build(self, tree):\n module = _PllScope(tree, "module", None)\n self.scopes.append(module)\n for stmt in tree.body:\n self._walk(stmt, module)\n return module\n\n def _add(self, scope, name, lineno, col, kind, module=None):\n scope.bindings.setdefault(name, []).append((lineno, col, kind, module))\n\n def _add_target(self, target, scope, kind):\n if isinstance(target, _ast.Name):\n self._add(scope, target.id, target.lineno, target.col_offset, kind)\n elif isinstance(target, (_ast.Tuple, _ast.List)):\n for elt in target.elts:\n self._add_target(elt, scope, kind)\n elif isinstance(target, _ast.Starred):\n self._add_target(target.value, scope, kind)\n else:\n # Subscript/Attribute targets are not name bindings.\n self._walk(target, scope)\n\n def _walk(self, node, scope):\n # --- Binding-creating statements ---------------------------------\n if isinstance(node, _ast.Assign):\n for t in node.targets:\n self._add_target(t, scope, "assign")\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AnnAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "annassign")\n else:\n self._walk(node.target, scope)\n if node.annotation is not None:\n self._walk(node.annotation, scope)\n if node.value is not None:\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AugAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "augassign")\n else:\n self._walk(node.target, scope)\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.For, _ast.AsyncFor)):\n self._add_target(node.target, scope, "for")\n self._walk(node.iter, scope)\n for s in node.body:\n self._walk(s, scope)\n for s in node.orelse:\n self._walk(s, scope)\n return\n if isinstance(node, (_ast.With, _ast.AsyncWith)):\n for item in node.items:\n self._walk(item.context_expr, scope)\n if item.optional_vars is not None:\n self._add_target(item.optional_vars, scope, "with")\n for s in node.body:\n self._walk(s, scope)\n return\n if isinstance(node, _ast.Import):\n for alias in node.names:\n name = alias.asname or alias.name.split(".", 1)[0]\n self._add(scope, name, node.lineno, node.col_offset, "import", alias.name)\n return\n if isinstance(node, _ast.ImportFrom):\n for alias in node.names:\n name = alias.asname or alias.name\n self._add(scope, name, node.lineno, node.col_offset, "importfrom", node.module)\n return\n if isinstance(node, _ast.NamedExpr):\n # Walrus :=\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "walrus")\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n # These don\'t create bindings.\n return\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n self._add(scope, node.name, node.lineno, node.col_offset, "functiondef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "function", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _ast.Lambda):\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "lambda", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n self._walk(node.body, inner)\n return\n if isinstance(node, _ast.ClassDef):\n self._add(scope, node.name, node.lineno, node.col_offset, "classdef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for b in node.bases:\n self._walk(b, scope)\n for kw in node.keywords:\n self._walk(kw.value, scope)\n inner = _PllScope(node, "class", scope)\n self.scopes.append(inner)\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _PLL_SCOPE_COMP):\n inner = _PllScope(node, "comprehension", scope)\n self.scopes.append(inner)\n # Per Python semantics: outermost iter is evaluated in the OUTER\n # scope, everything else inside the comp scope.\n for i, gen in enumerate(node.generators):\n if i == 0:\n self._walk(gen.iter, scope)\n else:\n self._walk(gen.iter, inner)\n self._add_target(gen.target, inner, "comprehension")\n for cond in gen.ifs:\n self._walk(cond, inner)\n if isinstance(node, _ast.DictComp):\n self._walk(node.key, inner)\n self._walk(node.value, inner)\n else:\n self._walk(node.elt, inner)\n return\n\n # --- Default: descend in the same scope --------------------------\n for child in _ast.iter_child_nodes(node):\n self._walk(child, scope)\n\n\nclass _PllKeywordVisitor(_ast.NodeVisitor):\n """Collect every `global` / `nonlocal` statement in a tree.\n\n Each entry is `(keyword, lineno, col_offset, names)` where `keyword`\n is the literal string "global" or "nonlocal" and `names` is the list\n of identifiers the statement applies to.\n """\n\n def __init__(self):\n self.found = []\n\n def visit_Global(self, node):\n self.found.append(("global", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n def visit_Nonlocal(self, node):\n self.found.append(("nonlocal", node.lineno, node.col_offset, list(node.names)))\n self.generic_visit(node)\n\n\ndef _pll_session_bound_names(session_key):\n """User-defined names already bound in a session (for REPL checks).\n\n Baseline names from `_pll_initial_globals` (image primitives, etc.) are\n skipped unless the user rebound them, so prompt analysis matches file\n analysis for the same snippet.\n """\n g = _pll_sessions.get(session_key)\n if not g:\n return []\n names = []\n for n in g:\n if n.startswith("_") or n in ("__builtins__", "__name__", "__doc__"):\n continue\n if n in _pll_initial_globals and g[n] is _pll_initial_globals[n]:\n continue\n names.append(n)\n return names\n\n\ndef _pll_predefined_library_names():\n """Public names every session starts with, mapped to their library.\n\n PYODIDE_INSTALL_PY seeds `_pll_initial_globals` with the image / table\n / reactor exports, so those names (`circle`, `rectangle`, `table`,\n `animate`, ...) are bound before the student writes anything. A student\n definition that reuses one is shadowing, exactly like a built-in - just\n with wording that says where the name comes from. The label is derived\n from the registered `pll` package when it is loaded, and falls back to\n the generic "library" otherwise (e.g. bootstrap-only test harnesses).\n """\n public = {n for n in _pll_initial_globals if not n.startswith("_")}\n labels = {}\n pll = _sys.modules.get("pll")\n if pll is not None:\n for lib in ("image", "table", "reactor"):\n mod = getattr(pll, lib, None)\n if mod is None:\n continue\n for n in public:\n if hasattr(mod, n):\n labels[n] = lib\n return {n: labels.get(n, "library") for n in public}\n\n\ndef _pll_is_pll_import_loc(loc):\n """True when a binding loc tuple imports from a `pll` module.\n\n `from pll.image import circle` re-binds the library\'s own value, so it\n shadows nothing and must not be flagged.\n """\n kind, module = loc[2], loc[3]\n return (\n kind in ("import", "importfrom")\n and isinstance(module, str)\n and (module == "pll" or module.startswith("pll."))\n )\n\n\ndef _pll_static_analyze(code, level, filename, session_key=None):\n """Run static checks for `level` over `code` and return findings.\n\n Returns a list of dicts. Each dict has at minimum:\n id, error_type, message, line_number, column, name_token, scope_kind.\n\n If `session_key` is set, names already bound in that session are treated\n as existing module-level bindings. That way a `#level beginner` prompt cannot\n reassign a name the file (or an earlier prompt line) already defined.\n """\n if level not in ("beginner", "intermediate"):\n return []\n try:\n tree = _ast.parse(code, filename=filename)\n except SyntaxError:\n # Let the runtime path surface SyntaxErrors with their normal flow.\n return []\n\n findings = []\n builder = _PllScopeBuilder()\n builder.build(tree)\n if session_key:\n module = builder.scopes[0]\n for name in _pll_session_bound_names(session_key):\n locs = module.bindings.setdefault(name, [])\n locs.insert(0, (0, 0, "preexisting", None))\n builtins_set = set(dir(_builtins_mod)) - _PLL_BUILTINS_MODULE_META\n library_names = _pll_predefined_library_names()\n\n # Whether we flag reassignment in `scope` at this level. At beginner,\n # we flag everywhere; at intermediate, only at module scope so that\n # function-local accumulator patterns (`total = 0; for x in xs:\n # total += x`) work.\n def reassignment_active(scope_kind):\n if level == "beginner":\n return True\n if level == "intermediate":\n return scope_kind == "module"\n return False\n\n for scope in builder.scopes:\n # Names visible from any enclosing scope, paired with the *nearest*\n # outer binding\'s location and scope kind. We walk parents inner-to-\n # outer and refuse to overwrite, so the closest enclosing binding wins\n # (which is the one Python\'s lookup rules would resolve to).\n enclosing = {} # name -> (lineno, col, scope_kind)\n cur = scope.parent\n while cur is not None:\n for outer_name, outer_locs in cur.bindings.items():\n if outer_name in enclosing:\n continue\n outer_first = outer_locs[0]\n enclosing[outer_name] = (outer_first[0], outer_first[1], cur.kind)\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n first_loc = locs[0]\n # A binding imported from a `pll` module re-binds the library\'s\n # own value and shadows nothing; a "preexisting" marker records\n # a name the session already bound (REPL analysis) rather than\n # one this code defines. Report at the first binding that\n # actually (re)defines the name; when there is none, this name\n # has nothing to report here.\n defining_loc = None\n for loc in locs:\n if not _pll_is_pll_import_loc(loc) and loc[2] != "preexisting":\n defining_loc = loc\n break\n report_loc = defining_loc if defining_loc is not None else first_loc\n if name in enclosing:\n shadowed_in_scope.add(name)\n outer = enclosing[name]\n findings.append({\n "id": "shadowing",\n "error_type": "Shadowing",\n "message": "`%s` is already defined in an outer scope" % name,\n "line_number": report_loc[0],\n "column": report_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "outer_line_number": outer[0],\n "outer_column": outer[1],\n "outer_scope_kind": outer[2],\n })\n elif name in builtins_set and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append({\n "id": "shadowing-builtin",\n "error_type": "Shadowing",\n "message": "`%s` is the name of a Python built-in" % name,\n "line_number": defining_loc[0],\n "column": defining_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n })\n elif name in library_names and defining_loc is not None:\n shadowed_in_scope.add(name)\n lib = library_names[name]\n findings.append({\n "id": "shadowing-library",\n "error_type": "Shadowing",\n "message": "`%s` is already defined by the %s library" % (name, lib),\n "line_number": defining_loc[0],\n "column": defining_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "library": lib,\n })\n\n # ---- Then reassignment (skip names already shadow-flagged) ----\n if reassignment_active(scope.kind):\n for name, locs in scope.bindings.items():\n if name in shadowed_in_scope:\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n findings.append({\n "id": "reassignment",\n "error_type": "Reassignment",\n "message": "`%s` is assigned more than once in this scope" % name,\n "line_number": second_loc[0],\n "column": second_loc[1],\n "name_token": name,\n "scope_kind": scope.kind,\n "first_line_number": first_loc[0],\n "first_column": first_loc[1],\n })\n\n # ---- `global` / `nonlocal` keyword check ----\n # Both are disallowed at beginner and intermediate. We emit one finding\n # per declaration (not per name) so a `global x, y` produces a single\n # diagnostic on that line.\n kw_visitor = _PllKeywordVisitor()\n kw_visitor.visit(tree)\n for keyword, lineno, col, names in kw_visitor.found:\n primary = names[0] if names else ""\n findings.append({\n "id": "disallowed-keyword",\n "error_type": "DisallowedKeyword",\n "message": "`%s` is not allowed at the %s level" % (keyword, level),\n "line_number": lineno,\n "column": col,\n "name_token": primary,\n "scope_kind": "function",\n "keyword": keyword,\n "names": list(names),\n })\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
165
-
166
- // 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("&", "&amp;")\n .replace("<", "&lt;")\n .replace(">", "&gt;")\n .replace(\'"\', "&quot;")\n .replace("\'", "&apos;")\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';
168
-
169
- // 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("&", "&amp;")
618
- .replace("<", "&lt;")
619
- .replace(">", "&gt;")
620
- .replace('"', "&quot;")
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
- }
175
+ // src/common/packages.ts
176
+ var PANDAS_METHOD_RE = /\.\s*to_pandas\s*\(/;
836
177
 
178
+ // src/common/interruptBuffer.ts
179
+ var INTERRUPT_SIGINT = 2;
180
+ function clearInterrupt(sab) {
181
+ Atomics.store(new Uint8Array(sab), 0, 0);
182
+ }
837
183
 
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
- }
184
+ // src/common/bootstrap/typeChecking.py
185
+ var typeChecking_default = '# Type checking: which checks a run makes, decided by its language level, and\n# typeguard - vendored, not installed - set up to make them.\n\nimport builtins as _pll_builtins\nimport sys as _sys\n\n# The language levels, as the host sends them: `LEVEL_NAMES` in level.ts\n# names the same four.\n_PLL_LEVEL_RAW = "raw"\n_PLL_LEVEL_BEGINNER = "beginner"\n_PLL_LEVEL_INTERMEDIATE = "intermediate"\n_PLL_LEVEL_ADVANCED = "advanced"\n\n#: The two that teach: static checks, and no bool where a number is annotated.\n_PLL_TEACHING_LEVELS = (_PLL_LEVEL_BEGINNER, _PLL_LEVEL_INTERMEDIATE)\n\n\ndef _pll_hidden(name, value):\n """Put `value` where PLL\'s instrumentation finds it, and return its name.\n\n Among the builtins, as `pll:name`: a name no program can write, so\n `dir()` does not list it and no assignment of the student\'s can hide\n it. The code PLL adds to theirs names it so.\n """\n key = "pll:" + name\n _pll_builtins.__dict__[key] = value\n return key\n\n\n#: The names PLL\'s instrumentation calls, among the builtins once set.\n_PLL_SHOW_TOP_LEVEL = "pll:show_top_level"\n_PLL_TG_MEMO = "pll:tg_memo"\n_PLL_TG_CHECK_ASSIGN = "pll:tg_check_assign"\n_PLL_CHECK_DATACLASS_FIELDS = "pll:check_dataclass_fields"\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\n\ndef _pll_rejects_bool(memo):\n """Whether a bool is refused where a number is annotated, for the code\n being checked: at its file\'s level, which its namespace records.\n\n Python counts True as 1 and both mypy and typeguard follow it, which is\n a 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 See `levelRejectsBoolAsNumber` in level.ts.\n """\n return getattr(memo, "globals", {}).get("__pll_level__") in _PLL_TEACHING_LEVELS\n\n\ndef _pll_check_int(value, origin_type, args, memo):\n """typeguard\'s `int` check, with bools refused at the teaching levels.\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 not isinstance(value, int) or (isinstance(value, bool) and _pll_rejects_bool(memo)):\n raise _pll_type_check_error("is not an instance of int")\n\n\ndef _pll_check_float(value, origin_type, args, memo):\n """As `_pll_check_int`, for the int-or-float numeric tower."""\n if not isinstance(value, (int, float)) or (isinstance(value, bool) and _pll_rejects_bool(memo)):\n raise _pll_type_check_error("is neither float or int")\n\n\ndef _pll_number_lookup(origin_type, args, extras):\n """typeguard checker lookup, consulted before its own: PLL\'s checks\n for `int` and `float`, which decide per check whether a bool counts."""\n if origin_type is int:\n return _pll_check_int\n if origin_type is float:\n return _pll_check_float\n return None\n\n\ndef _pll_checks_annotations(level):\n """Whether code at `level` has its annotations checked as it runs.\n\n Every level but `raw`, which exists so a file can opt out; there is no\n separate setting, so nothing can disagree with the level. How strictly\n a number is checked is decided later, per check (`_pll_rejects_bool`).\n """\n return level != _PLL_LEVEL_RAW\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 # Called by the code `_PllTopLevelAnnAssign` and\n # `_PllDataclassChecks` add.\n _pll_hidden("tg_memo", TypeCheckMemo)\n _pll_hidden("tg_check_assign", check_variable_assignment)\n _pll_hidden("check_dataclass_fields", _pll_check_dataclass_fields)\n # Consulted before typeguard\'s builtin lookup.\n _pll_type_check_error = TypeCheckError\n if _pll_number_lookup not in checker_lookup_functions:\n checker_lookup_functions.insert(0, _pll_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';
847
186
 
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)
187
+ // src/common/bootstrap/errorInfo.py
188
+ var errorInfo_default = '# Describing an exception for the host: the message as Python would display\n# it, the line and column, the student\'s own frames, and the facts the\n# explanations need (a name, a sequence and its length, ...) - as data, so\n# no explanation ever has to parse Python\'s wording back out of a message.\n\nimport traceback as _tb_mod\nimport linecache as _pll_linecache\nimport ast as _ast\nimport re as _pll_src_re\nimport inspect as _pll_inspect\nimport weakref as _pll_weakref\n\n#: The checkers PLL gives typeguard for numbers, in place of its own.\n_PLL_CHECKERS = ("_pll_check_int", "_pll_check_float")\n\n\ndef _pll_is_vendor_frame(filename, name=None):\n """Whether a frame is the type checker\'s: vendored typeguard, or one of\n the checkers PLL gives it, which do typeguard\'s job."""\n if not isinstance(filename, str):\n return False\n return filename.startswith(_PLL_VENDOR_DIR) or (\n filename == "<pll:bootstrap/typeChecking>" and name in _PLL_CHECKERS\n )\n\n\n#: How typeguard words what failed: `<subject> (<actual type>) <predicate>`.\n_PLL_TC_PREDICATES = (\n (_pll_src_re.compile(r"^(.*) is not an instance of (.+)$"), lambda m: [m.group(2)]),\n (_pll_src_re.compile(r"^(.*) is neither float or int$"), lambda m: ["float"]),\n (_pll_src_re.compile(r"^(.*) is not None$"), lambda m: ["None"]),\n (_pll_src_re.compile(r"^(.*) is not a ([A-Za-z_][A-Za-z0-9_]*)$"), lambda m: [m.group(2)]),\n)\n_PLL_TC_UNION = " did not match any element in the union:"\n_PLL_TC_UNION_MEMBER_RE = _pll_src_re.compile(r"^\\s*([A-Za-z_][A-Za-z0-9_.\\[\\], ]*):")\n_PLL_TC_ACTUAL_RE = _pll_src_re.compile(r"^(.*) \\(([^()]*)\\)$")\n#: Something inside a collection: `item 2 of argument "nums"`.\n_PLL_TC_ELEMENT_RE = _pll_src_re.compile(r"^(item \\d+|key .+|value of key .+|\\[.+\\]) of (.+)$")\n_PLL_TC_ARGUMENT_RE = _pll_src_re.compile(r\'^argument "(.+)"$\')\n_PLL_TC_ASSIGNED_RE = _pll_src_re.compile(r"^value assigned to (.+)$")\n\n\ndef _pll_type_check_parts(message):\n """typeguard\'s message, read once into its parts, for the host.\n\n `kind` is what the annotation is on - "argument", "return", "variable",\n or "unknown" when the wording is unfamiliar - `name` its name, `element`\n typeguard\'s words for the part of a collection that failed ("item 2",\n "value of key \'a\'"), `actual` the type the value had, and `expected`\n the types the annotation accepts.\n """\n lines = message.split("\\n")\n first = lines[0].strip()\n parts = {"kind": "unknown", "name": None, "element": None, "actual": None, "expected": []}\n subject = None\n at = first.find(_PLL_TC_UNION)\n if at >= 0:\n subject = first[:at]\n for raw in lines[1:]:\n member = _PLL_TC_UNION_MEMBER_RE.match(raw)\n if member:\n parts["expected"].append(member.group(1).strip())\n else:\n for pattern, pick in _PLL_TC_PREDICATES:\n found = pattern.match(first)\n if found:\n subject = found.group(1)\n parts["expected"] = pick(found)\n break\n if subject is None:\n return parts\n actual = _PLL_TC_ACTUAL_RE.match(subject)\n if actual:\n subject, parts["actual"] = actual.group(1), _pll_course_type_name(actual.group(2))\n element = _PLL_TC_ELEMENT_RE.match(subject)\n if element:\n parts["element"], subject = element.group(1), element.group(2)\n argument = _PLL_TC_ARGUMENT_RE.match(subject)\n assigned = _PLL_TC_ASSIGNED_RE.match(subject)\n if subject == "the return value":\n parts["kind"] = "return"\n elif argument:\n parts["kind"], parts["name"] = "argument", argument.group(1)\n elif assigned:\n parts["kind"], parts["name"] = "variable", assigned.group(1)\n return parts\n\n\ndef _pll_course_type_name(name):\n """`Image` for any of the image library\'s own classes - `_Circle`, a\n chart - which a student has never seen; any other name as it is."""\n found = globals().get(name.rsplit(".", 1)[-1])\n image = globals().get("Image")\n if isinstance(found, type) and isinstance(image, type) and issubclass(found, image):\n return "Image"\n return name\n\n\ndef _pll_add_facts(exc, **facts):\n """Record what was learned about `exc` for the host\'s explanations.\n\n Facts travel beside the message, never in it: the message stays the one\n Python wrote, and the host reads a fact by name rather than by pattern.\n """\n try:\n known = getattr(exc, "_pll_facts", None) or {}\n known.update(facts)\n exc._pll_facts = known\n except Exception:\n pass\n\n\ndef _pll_student_frames(exc):\n """The live frames of `exc`\'s traceback that run the student\'s code."""\n frames = []\n tb = exc.__traceback__\n while tb is not None:\n if _pll_is_students(tb.tb_frame.f_code.co_filename):\n frames.append((tb.tb_frame, tb.tb_lineno))\n tb = tb.tb_next\n return frames\n\n\n#: The input each prompt line\'s code was compiled from. Every prompt line\n#: is `<repl>`, so the name cannot say which one a frame is in: a function\n#: defined at an earlier prompt line runs code from that line\'s input.\n_pll_prompt_sources = _pll_weakref.WeakKeyDictionary()\n\n\ndef _pll_remember_prompt_source(code, source):\n """Record `source` for `code` and every function compiled inside it."""\n _pll_prompt_sources[code] = source\n for const in code.co_consts:\n if isinstance(const, type(code)):\n _pll_remember_prompt_source(const, source)\n\n\ndef _pll_source_of(code, run):\n """The source `code` was compiled from, as it ran.\n\n A prompt line\'s own input; the run\'s code for the file that was run -\n `run` is `(filename, code)`, and the file need not be on disk; and any\n other file as it is on disk.\n """\n prompt = _pll_prompt_sources.get(code)\n if prompt is not None:\n return prompt\n filename = code.co_filename\n if run is not None and filename == run[0]:\n return run[1]\n _pll_linecache.checkcache(filename)\n return "".join(_pll_linecache.getlines(filename))\n\n\ndef _pll_source_line(code, lineno, run):\n """Line `lineno` of the source `code` was compiled from."""\n lines = _pll_source_of(code, run).split("\\n")\n return lines[lineno - 1].rstrip("\\r") if 0 < lineno <= len(lines) else ""\n\n\ndef _pll_enrich_index_error(exc, run):\n """Record how long the list really is, for an `IndexError`.\n\n "list index out of range" says nothing about the list, and the\n explanation was left to illustrate with a made-up list of 3. The frame\n the error was raised in holds the real one, and the line says which\n name was subscripted.\n """\n if type(exc) is not IndexError or "out of range" not in str(exc):\n return\n frames = _pll_student_frames(exc)\n if not frames:\n return\n frame, lineno = frames[-1]\n text = _pll_source_line(frame.f_code, lineno, run)\n for name in _pll_src_re.findall(r"([A-Za-z_]\\w*)\\s*\\[", text):\n value = frame.f_locals.get(name, frame.f_globals.get(name))\n if isinstance(value, (list, tuple, str)):\n _pll_add_facts(exc, sequence=name, length=len(value))\n return\n\n\ndef _pll_return_annotation(frame, run):\n """The return annotation of the function `frame` runs, as written."""\n code = frame.f_code\n try:\n source = _pll_source_of(code, run)\n with _pll_without_syntax_warnings():\n tree = _ast.parse(source)\n except (SyntaxError, ValueError):\n return None\n for node in _ast.walk(tree):\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)) and node.name == code.co_name:\n starts = [node.lineno] + [d.lineno for d in node.decorator_list]\n if code.co_firstlineno in starts and node.returns is not None:\n return _ast.get_source_segment(source, node.returns)\n return None\n\n\ndef _pll_enrich_type_check(exc, run=None):\n """Record what failed its annotation, as parts (`check`) - and, for an\n element of an argument, what the element actually is.\n\n typeguard names the element that failed - "item 0 of argument "lst"\n (list) is not an instance of float" - but not what it is, which is the\n one thing a student needs to see: here, the string "1". The value is\n only reachable from the frame the check fired in.\n\n Only frames from the student\'s own files are searched: typeguard\'s own\n functions have locals with ordinary names like `value`, and finding one\n of those first would describe the wrong thing.\n """\n if type(exc).__name__ != "TypeCheckError" or not exc.args:\n return\n # A check PLL raised itself has its parts already.\n check = (getattr(exc, "_pll_facts", None) or {}).get("check")\n if check is None:\n check = _pll_type_check_parts(str(exc))\n # The level of the code checked, which may not be the run\'s: an\n # argument is checked in the function it is passed to.\n frames = _pll_student_frames(exc)\n check["level"] = frames[-1][0].f_globals.get("__pll_level__") if frames else None\n if check["kind"] == "return" and frames:\n check["annotation"] = _pll_return_annotation(frames[-1][0], run)\n _pll_add_facts(exc, check=check)\n element, name = check.get("element"), check.get("name")\n if check["kind"] not in ("argument", "return") or element is None:\n return\n item = _pll_src_re.match(r"^item (\\d+)$", element)\n keyed = _pll_src_re.match(r"^value of key (.+)$", element)\n if item is None and keyed is None:\n return\n if check["kind"] == "return":\n # The value returned is a local of typeguard\'s own checker.\n tb = exc.__traceback__\n container = None\n while tb is not None:\n if tb.tb_frame.f_code.co_name == "check_return_type_internal":\n container = tb.tb_frame.f_locals.get("retval")\n tb = tb.tb_next\n if container is None:\n return\n else:\n for frame, _line in reversed(_pll_student_frames(exc)):\n if name in frame.f_locals:\n container = frame.f_locals[name]\n break\n else:\n return\n try:\n value = container[int(item.group(1))] if item else container[_ast.literal_eval(keyed.group(1))]\n except Exception:\n return\n _pll_add_facts(\n exc,\n element_value=_pll_describe(value),\n element_type=_pll_course_type_name(type(value).__name__),\n )\n\n\ndef _pll_innermost_tb(tb, count):\n """`tb` from its innermost `count` frames on, and how many it skips.\n\n A `RecursionError` under a raised `sys.setrecursionlimit` has tens of\n thousands of frames, and reading each one\'s line takes seconds.\n """\n nodes = []\n while tb is not None:\n nodes.append(tb)\n tb = tb.tb_next\n if len(nodes) <= count:\n return (nodes[0] if nodes else None), 0\n return nodes[-count], len(nodes) - count\n\n\ndef _pll_format_exception(exc):\n """`format_exception`, minus frames inside the vendored type checker,\n and of a very deep traceback only the innermost frames.\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 Python shows them.\n """\n try:\n tb, skipped = _pll_innermost_tb(exc.__traceback__, _PLL_MAX_FRAMES * 3)\n frames = _tb_mod.extract_tb(tb)\n kept = [f for f in frames if not _pll_is_vendor_frame(f.filename, f.name)]\n if len(kept) == len(frames) and not skipped:\n return "".join(_tb_mod.format_exception(type(exc), exc, exc.__traceback__))\n parts = ["Traceback (most recent call last):\\n"]\n if skipped:\n parts.append(" [%d earlier calls not shown]\\n" % skipped)\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\n#: Files that are not the student\'s: the vendored type checker, the\n#: standard library, installed packages, and pytest.\n_PLL_NOT_STUDENT = (_PLL_VENDOR_DIR, "site-packages", "/lib/python", "_pytest", "pluggy")\n\n\ndef _pll_is_students(filename):\n """Whether a frame in `filename` runs the student\'s code: one of their\n files, or a prompt line (`<repl>`). Any other `<...>` is code no file\n holds - PLL\'s own (`<pll:...>`), or code Python made from a string\n (`<string>`, as `typing` and `dataclasses` do, and `<frozen ...>`).\n """\n if filename == "<repl>":\n return True\n if filename.startswith("<"):\n return False\n return not any(token in filename for token in _PLL_NOT_STUDENT)\n\n#: How Python words a name used before it has a value.\n_PLL_UNBOUND_RE = _pll_src_re.compile(r"cannot access (?:free|local) variable \'(\\w+)\'")\n\n#: Innermost frames sent with an error. A `RecursionError` has a thousand,\n#: and the rules that read frames want only the last few.\n_PLL_MAX_FRAMES = 100\n\n\ndef _pll_shown_file(filename):\n """A file as the student knows it: one next to their program by its own\n name, rather than the work directory\'s path."""\n prefix = _PLL_WORK_DIR + "/"\n if isinstance(filename, str) and filename.startswith(prefix):\n return filename[len(prefix) :]\n return filename\n\n\ndef _pll_parameters(code):\n """The parameters of the function `code` runs, or None at module level."""\n if code.co_name == "<module>":\n return None\n count = code.co_argcount + code.co_kwonlyargcount\n count += bool(code.co_flags & _pll_inspect.CO_VARARGS)\n count += bool(code.co_flags & _pll_inspect.CO_VARKEYWORDS)\n return list(code.co_varnames[:count])\n\n\ndef _pll_frame_text(summary, code, run):\n """The student\'s line a frame is at, as written, or None."""\n return _pll_source_line(code, summary.lineno, run) or None\n\n\ndef _pll_frame_column(summary, code, run):\n """The 0-based column `summary`\'s frame failed at, or None.\n\n None where Python\'s own traceback draws no caret: when it has no source\n for the line, or when the failing expression is the whole line.\n """\n if summary.colno is None or summary.end_colno is None:\n return None\n text = _pll_source_line(code, summary.lineno, run)\n if not text:\n return None\n # Python counts these in UTF-8 bytes; a column counts characters.\n encoded = text.encode("utf-8")\n start = len(encoded[: summary.colno].decode("utf-8", "replace"))\n if summary.end_lineno == summary.lineno:\n end = len(encoded[: summary.end_colno].decode("utf-8", "replace"))\n if text[:start].strip() == "" and text[end:].strip() == "":\n return None\n return start\n\n\n#: Linux\'s number for each error name: what CPython says in `[Errno 2]`.\n#: Pyodide\'s are Emscripten\'s, so the same missing file is `[Errno 44]`.\n_PLL_LINUX_ERRNO = {\n "EPERM": 1, "ENOENT": 2, "EINTR": 4, "EIO": 5, "EBADF": 9, "EAGAIN": 11, "ENOMEM": 12,\n "EACCES": 13, "EBUSY": 16, "EEXIST": 17, "EXDEV": 18, "ENOTDIR": 20, "EISDIR": 21,\n "EINVAL": 22, "EMFILE": 24, "EFBIG": 27, "ENOSPC": 28, "ESPIPE": 29, "EROFS": 30,\n "EPIPE": 32, "ERANGE": 34, "ENAMETOOLONG": 36, "ENOTEMPTY": 39, "ELOOP": 40,\n "ECONNREFUSED": 111, "ETIMEDOUT": 110, "EHOSTUNREACH": 113,\n}\n\n\ndef _pll_linux_errno(message, exc):\n """`message` with an `OSError`\'s `[Errno 44]` as CPython on Linux says it."""\n number = getattr(exc, "errno", None)\n if not isinstance(exc, OSError) or not isinstance(number, int):\n return message\n try:\n import errno as _pll_errno\n\n linux = _PLL_LINUX_ERRNO.get(_pll_errno.errorcode.get(number))\n except Exception:\n linux = None\n if linux is None or linux == number:\n return message\n return message.replace("[Errno %d]" % number, "[Errno %d]" % linux, 1)\n\n\ndef _pll_displayed_message(exc):\n """The message as Python\'s traceback shows it - as CPython would.\n\n Not always `str(exc)`: Python adds its suggestion ("Did you mean:\n \'total\'?") to a `NameError` or `AttributeError` only when it displays\n one, and a `SyntaxError`\'s `str` carries the file and line, which the\n display puts elsewhere. An `OSError`\'s number is CPython\'s on Linux.\n """\n return _pll_linux_errno(_pll_displayed_message_as_raised(exc), exc)\n\n\ndef _pll_displayed_message_as_raised(exc):\n exc_type = type(exc)\n shown = exc_type.__qualname__\n if exc_type.__module__ not in ("__main__", "builtins"):\n shown = "%s.%s" % (exc_type.__module__, shown)\n try:\n lines = _tb_mod.TracebackException(\n exc_type, exc, exc.__traceback__, limit=0, compact=True\n ).format_exception_only()\n for text in lines:\n if text.startswith(shown + ": "):\n return text[len(shown) + 2 :].rstrip("\\n")\n if text.rstrip("\\n") == shown:\n return ""\n except Exception:\n pass\n return exc.msg if isinstance(exc, SyntaxError) else str(exc)\n\n\ndef _pll_error_info(exc, run=None):\n """Everything the host needs to report `exc`, as data.\n\n The one place an exception becomes a result, for a file run, a prompt\n line, a test and a reactor alike. The host reads these\n fields rather than the traceback text: `traceback` is kept only to show\n when nothing better can be said.\n\n `run` is `(filename, code)` for what was run, which the lines of its\n frames are read from (see `_pll_source_of`).\n\n - `error_frames`: outermost first, each `{file, line, column, function,\n user, text, parameters}`, where `user` says whether the frame runs the\n student\'s code, and `text` and `parameters` are their line there and\n the parameters of their function.\n - `error_facts`: what was learned from the live frames - the name a\n `NameError` is about, a sequence\'s real length, the value that failed\n its annotation, a swapped field - and from the code (`_pll_code_facts`).\n """\n _pll_enrich_type_check(exc, run)\n _pll_enrich_index_error(exc, run)\n _pll_enrich_module_not_found(exc)\n # The same frames twice over: summaries for their lines, and the live\n # ones for the code they run.\n # Read from far enough in that the vendored frames left out still leave\n # `_PLL_MAX_FRAMES` of the rest.\n tb, _skipped = _pll_innermost_tb(exc.__traceback__, _PLL_MAX_FRAMES * 3)\n entries = [\n (s, live.f_code)\n for s, (live, _line) in zip(_tb_mod.extract_tb(tb), _tb_mod.walk_tb(tb))\n if not _pll_is_vendor_frame(s.filename, s.name)\n ][-_PLL_MAX_FRAMES:]\n frames = []\n for s, code in entries:\n user = _pll_is_students(s.filename)\n frames.append({\n "file": _pll_shown_file(s.filename),\n "line": s.lineno,\n "column": _pll_frame_column(s, code, run),\n "function": None if s.name == "<module>" else s.name,\n "user": user,\n # The line itself, so the host never reads it out of the wrong\n # file: a frame can be in any of the student\'s files.\n "text": _pll_frame_text(s, code, run) if user else None,\n "parameters": _pll_parameters(code) if user else None,\n })\n facts = dict(getattr(exc, "_pll_facts", None) or {})\n try:\n facts.update(_pll_code_facts(exc, run))\n except Exception:\n # Facts only add to an explanation; the error itself still has to\n # be reported if reading them fails.\n pass\n if isinstance(exc, NameError):\n # Python sets `name` for most of these, but not for a local read\n # before it is assigned; its message names it either way.\n name = getattr(exc, "name", None)\n if not isinstance(name, str):\n found = _PLL_UNBOUND_RE.search(str(exc))\n name = found.group(1) if found else None\n if name is not None:\n facts["name"] = name\n if isinstance(exc, SyntaxError):\n where = {\n "file": _pll_shown_file(exc.filename),\n "line": exc.lineno,\n "column": exc.offset - 1 if exc.offset else None,\n "text": exc.text.rstrip("\\n") if exc.text else None,\n }\n elif frames:\n where = frames[-1]\n else:\n where = {"file": None, "line": None, "column": None, "text": None}\n return {\n "error_type": type(exc).__name__,\n "error_message": _pll_displayed_message(exc),\n "traceback": _pll_format_exception(exc),\n "error_file": where["file"],\n "line_number": where["line"],\n "column": where["column"],\n "error_text": where.get("text"),\n "error_frames": frames,\n "error_facts": facts,\n }\n';
852
189
 
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
190
+ // src/common/bootstrap/codeFacts.py
191
+ var codeFacts_default = '# What the student\'s code says about an error, read from the live objects\n# and the code that raised: the definitions it names, where the names on\n# its line were last set, and how a function that returned `None` is built.\n# The explanations need these, and only Python can see them across files:\n# `shapes.area(3)` names a function defined somewhere else entirely.\n\nimport ast as _ast\nimport dataclasses as _pll_dataclasses\nimport inspect as _pll_inspect\nimport io as _pll_io\nimport keyword as _pll_keyword\nimport linecache as _pll_linecache\nimport re as _pll_src_re\nimport sys as _pll_sys\nimport tokenize as _pll_tokenize\nimport types as _pll_types\nimport typing as _pll_typing\n\n#: At most this many definitions travel with one error.\n_PLL_MAX_DEFINITIONS = 20\n\n#: The names in a message that name something defined: `area()`,\n#: `Song.__init__()`, `\'Song\' object`, `type object \'Song\'`.\n_PLL_NAMED_IN_MESSAGE = (\n _pll_src_re.compile(r"\\b([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*)\\(\\)"),\n _pll_src_re.compile(r"\'([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*)\' object"),\n _pll_src_re.compile(r"type object \'([A-Za-z_]\\w*)\'"),\n)\n\n#: PLL\'s own namespace, which the libraries share with the bootstrap.\n_PLL_OWN = globals()\n\n_PLL_POSITIONAL = (\n _pll_inspect.Parameter.POSITIONAL_ONLY,\n _pll_inspect.Parameter.POSITIONAL_OR_KEYWORD,\n)\n\n\ndef _pll_is_own(obj):\n """Whether `obj` is one of PLL\'s own functions or classes."""\n name = getattr(obj, "__name__", None)\n return isinstance(name, str) and _PLL_OWN.get(name) is obj\n\n\ndef _pll_is_students_class(cls):\n """Whether `cls` was written in one of the student\'s files.\n\n Not by its module\'s name alone: PLL\'s libraries run as `__main__` too.\n """\n if _pll_is_own(cls):\n return False\n if cls.__module__ == "__main__":\n return True\n module = _pll_sys.modules.get(cls.__module__)\n filename = getattr(module, "__file__", None)\n return isinstance(filename, str) and _pll_is_students(filename)\n\n\ndef _pll_union_members(obj):\n """The types a union is made of, by name, or None if `obj` is not one."""\n if not (isinstance(obj, _pll_types.UnionType) or _pll_typing.get_origin(obj) is _pll_typing.Union):\n return None\n names = []\n for member in _pll_typing.get_args(obj):\n name = "None" if member is type(None) else getattr(member, "__name__", None)\n if isinstance(name, str):\n names.append(name)\n return names\n\n\ndef _pll_definition(obj, drop_first=False):\n """What `obj` is, as the explanations use it, or None.\n\n - a function: its positional `parameters`, and the `required` ones;\n `drop_first` leaves out a method\'s `self`\n - a class: whether it is the student\'s, and if so its `fields`, in\n order, and whether it is a `dataclass`\n - a union: its `members`\n """\n if isinstance(obj, type):\n students = _pll_is_students_class(obj)\n fields = []\n if students:\n if _pll_dataclasses.is_dataclass(obj):\n fields = [field.name for field in _pll_dataclasses.fields(obj)]\n else:\n fields = list(_pll_inspect.get_annotations(obj))\n return {\n "kind": "class",\n "students": students,\n "fields": fields,\n "dataclass": students and _pll_dataclasses.is_dataclass(obj),\n }\n members = _pll_union_members(obj)\n if members is not None:\n return {"kind": "union", "members": members}\n if not (_pll_inspect.isfunction(obj) or _pll_inspect.ismethod(obj)):\n return None\n try:\n parameters = list(_pll_inspect.signature(obj).parameters.values())\n except (TypeError, ValueError):\n return None\n if drop_first:\n parameters = parameters[1:]\n positional = [p for p in parameters if p.kind in _PLL_POSITIONAL]\n return {\n "kind": "function",\n "parameters": [p.name for p in positional],\n "required": [p.name for p in positional if p.default is p.empty],\n }\n\n\n_PLL_UNRESOLVED = object()\n\n\ndef _pll_resolve(chain, scopes):\n """`obj`, and whether it is a method reached through its class or one of\n its instances, for a dotted name like `shapes.area` - looked up without\n running anything (no properties, no `__getattr__`)."""\n head, *rest = chain.split(".")\n obj = _PLL_UNRESOLVED\n for scope in scopes:\n if head in scope:\n obj = scope[head]\n break\n if obj is _PLL_UNRESOLVED:\n return _PLL_UNRESOLVED, False\n method = False\n for attr in rest:\n try:\n found = _pll_inspect.getattr_static(obj, attr)\n except AttributeError:\n return _PLL_UNRESOLVED, False\n method = not isinstance(obj, _pll_types.ModuleType)\n if isinstance(found, staticmethod):\n found, method = found.__func__, False\n elif isinstance(found, classmethod):\n found = found.__func__\n obj = found\n return obj, method and _pll_inspect.isfunction(obj)\n\n\ndef _pll_line_names(text):\n """The names on a line, dotted ones whole: `shapes.area`, `print`."""\n chains = []\n current = None\n after_dot = False\n try:\n for token in _pll_tokenize.generate_tokens(_pll_io.StringIO(text).readline):\n if token.type == _pll_tokenize.NAME and not _pll_keyword.iskeyword(token.string):\n current = current + "." + token.string if after_dot and current else token.string\n after_dot = False\n continue\n if token.type == _pll_tokenize.OP and token.string == "." and current:\n after_dot = True\n continue\n if current:\n chains.append(current)\n current, after_dot = None, False\n except (_pll_tokenize.TokenError, SyntaxError):\n pass\n if current:\n chains.append(current)\n return chains\n\n\ndef _pll_definitions(exc, frame, text):\n """The definitions an explanation of `exc` might name: those its\n message names, and those named on the student\'s line.\n\n Keyed as written - `shapes.area` - and by the last part too, which is\n how Python\'s message names it (`area() missing ...`).\n """\n # PLL\'s own classes - `Table` in `Table.scatter_plot()` - are among\n # every session\'s globals.\n scopes = [frame.f_locals, frame.f_globals, frame.f_builtins]\n message = str(exc)\n chains = []\n for pattern in _PLL_NAMED_IN_MESSAGE:\n for chain in pattern.findall(message):\n parts = chain.split(".")\n # `Song.__init__()` is about `Song` as much as its `__init__`.\n chains.extend(".".join(parts[: i + 1]) for i in range(len(parts)))\n chains.extend(_pll_line_names(text or ""))\n found = {}\n # The class of the value an attribute was asked of, which no name may\n # reach: `\'Song\' object has no attribute \'yaer\'`.\n if isinstance(exc, AttributeError) and getattr(exc, "obj", None) is not None:\n cls = exc.obj if isinstance(exc.obj, type) else type(exc.obj)\n described = _pll_definition(cls)\n if described is not None:\n found[cls.__name__] = described\n for chain in chains:\n if len(found) >= _PLL_MAX_DEFINITIONS:\n break\n if chain in found:\n continue\n obj, method = _pll_resolve(chain, scopes)\n if obj is _PLL_UNRESOLVED:\n continue\n described = _pll_definition(obj, drop_first=method)\n if described is None:\n continue\n found[chain] = described\n last = chain.rsplit(".", 1)[-1]\n if last != chain and last not in found:\n found[last] = described\n return found\n\n\ndef _pll_tree_of(code, run):\n """The parsed source `code` was compiled from, as it ran, or None."""\n source = _pll_source_of(code, run)\n if not source:\n return None, ""\n try:\n with _pll_without_syntax_warnings():\n return _ast.parse(source), source\n except (SyntaxError, ValueError):\n return None, source\n\n\ndef _pll_function_node(tree, code, lineno):\n """The `def` that `code` runs, found by its name and the line inside it."""\n best = None\n for node in _ast.walk(tree):\n if (\n isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef))\n and node.name == code.co_name\n and node.lineno <= lineno <= (node.end_lineno or node.lineno)\n ):\n # The innermost, for a function defined inside another.\n if best is None or node.lineno >= best.lineno:\n best = node\n return best\n\n\ndef _pll_scope_statements(body):\n """Every statement of a scope, in order, into `if`s and loops but not\n into the functions and classes it defines."""\n for statement in body:\n yield statement\n if isinstance(statement, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n continue\n for field in ("body", "orelse", "finalbody"):\n yield from _pll_scope_statements(getattr(statement, field, None) or [])\n for handler in getattr(statement, "handlers", None) or []:\n yield from _pll_scope_statements(handler.body)\n for case in getattr(statement, "cases", None) or []:\n yield from _pll_scope_statements(case.body)\n\n\ndef _pll_dotted(node):\n """`f` or `x.append`, for the function a call calls, or None."""\n parts = []\n while isinstance(node, _ast.Attribute):\n parts.append(node.attr)\n node = node.value\n if not isinstance(node, _ast.Name):\n return None\n parts.append(node.id)\n return ".".join(reversed(parts))\n\n\ndef _pll_calls_assigned(body, names, before):\n """For each of `names` set from a call in `body`: the call and its line,\n the last before line `before` - or after it, when none is before (a\n loop goes round)."""\n earlier, later = {}, {}\n for statement in _pll_scope_statements(body):\n if isinstance(statement, _ast.Assign) and len(statement.targets) == 1:\n target, value = statement.targets[0], statement.value\n elif isinstance(statement, _ast.AnnAssign) and statement.value is not None:\n target, value = statement.target, statement.value\n else:\n continue\n if not (isinstance(target, _ast.Name) and target.id in names and isinstance(value, _ast.Call)):\n continue\n call = _pll_dotted(value.func)\n if call is None:\n continue\n side = earlier if statement.lineno < before else later\n if side is earlier or target.id not in later:\n side[target.id] = {"call": call, "line": statement.lineno}\n return {**later, **earlier}\n\n\ndef _pll_assigned(frame, lineno, tree, names):\n """Where the names on the failing line were last set from a call: in\n the function the error is in, or - for a global - in its file."""\n code = frame.f_code\n node = _pll_function_node(tree, code, lineno) if code.co_name != "<module>" else None\n local = set(code.co_varnames) if node is not None else set()\n found = {}\n if node is not None:\n found.update(_pll_calls_assigned(node.body, names & local, lineno))\n module_before = lineno if node is None else float("inf")\n for name, where in _pll_calls_assigned(tree.body, names - local, module_before).items():\n found.setdefault(name, where)\n return found\n\n\ndef _pll_union_named(annotation, frame):\n """The members of the union an annotation names, or None."""\n if isinstance(annotation, _ast.Name):\n value = frame.f_globals.get(annotation.id, frame.f_builtins.get(annotation.id))\n return _pll_union_members(value)\n if isinstance(annotation, _ast.BinOp) and isinstance(annotation.op, _ast.BitOr):\n left = _pll_union_named(annotation.left, frame)\n right = _pll_union_named(annotation.right, frame)\n names = []\n for side, part in ((left, annotation.left), (right, annotation.right)):\n if side is not None:\n names.extend(side)\n elif isinstance(part, _ast.Name):\n names.append(part.id)\n elif isinstance(part, _ast.Constant) and part.value is None:\n names.append("None")\n else:\n return None\n return names\n return None\n\n\ndef _pll_pattern_classes(pattern):\n """The classes a `case` pattern matches, or None for one that matches\n anything (`case _:`, `case x:`)."""\n if isinstance(pattern, _ast.MatchAs):\n return None if pattern.pattern is None else _pll_pattern_classes(pattern.pattern)\n if isinstance(pattern, _ast.MatchOr):\n names = set()\n for alternative in pattern.patterns:\n inner = _pll_pattern_classes(alternative)\n if inner is None:\n return None\n names |= inner\n return names\n if isinstance(pattern, _ast.MatchClass):\n name = _pll_dotted(pattern.cls)\n return {name.rsplit(".", 1)[-1]} if name else set()\n if isinstance(pattern, _ast.MatchSingleton) and pattern.value is None:\n return {"None"}\n return set()\n\n\ndef _pll_trailing_match(node, source, frame):\n """The `match` a function ends with, when it ends with one.\n\n A `match` no `case` fits does nothing, and the function then runs off\n its end and returns `None`.\n """\n last = node.body[-1] if node.body else None\n if not isinstance(last, _ast.Match):\n return None\n segment = lambda part: _ast.get_source_segment(source, part) or ""\n patterns = [case.pattern for case in last.cases]\n fixed = [\n segment(p)\n for p in patterns\n if isinstance(p, _ast.MatchSequence)\n and len(p.patterns) >= 2\n and not any(isinstance(inner, _ast.MatchStar) for inner in p.patterns)\n and segment(p).startswith("[")\n ]\n lists = [p for p in patterns if isinstance(p, _ast.MatchSequence) and segment(p).startswith("[")]\n # The members of the union being matched that no `case` names - only\n # when the subject is a parameter annotated with a union.\n uncovered = []\n subject = last.subject\n if isinstance(subject, _ast.Name):\n arguments = node.args.posonlyargs + node.args.args + node.args.kwonlyargs\n annotation = next((a.annotation for a in arguments if a.arg == subject.id), None)\n members = _pll_union_named(annotation, frame) if annotation is not None else None\n if members:\n covered = set()\n for pattern in patterns:\n classes = _pll_pattern_classes(pattern)\n if classes is None:\n covered = None\n break\n covered |= classes\n if covered is not None:\n uncovered = [member for member in members if member not in covered]\n return {\n "subject": segment(subject),\n "patterns": [segment(p) for p in patterns],\n "fixed_length": fixed,\n "has_list": len(lists) > 0,\n "uncovered": uncovered,\n }\n\n\ndef _pll_blocks(body):\n """Every block of statements in a function body, the body included,\n not into the functions and classes it defines."""\n yield body\n for statement in body:\n if isinstance(statement, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n continue\n for field in ("body", "orelse", "finalbody"):\n inner = getattr(statement, field, None)\n if inner:\n yield from _pll_blocks(inner)\n for handler in getattr(statement, "handlers", None) or []:\n yield from _pll_blocks(handler.body)\n for case in getattr(statement, "cases", None) or []:\n yield from _pll_blocks(case.body)\n\n\ndef _pll_print_ending_a_branch(node, source):\n """The first `print` that is the last statement of its block.\n\n A branch that ends in `print` shows the right answer and returns\n nothing. A `print` followed by more in its block is just output.\n """\n found = None\n for block in _pll_blocks(node.body):\n last = block[-1]\n call = last.value if isinstance(last, _ast.Expr) else None\n if not (isinstance(call, _ast.Call) and isinstance(call.func, _ast.Name) and call.func.id == "print"):\n continue\n if found is not None and found["line"] <= last.lineno:\n continue\n single = len(call.args) == 1 and not call.keywords and not isinstance(call.args[0], _ast.Starred)\n found = {\n "line": last.lineno,\n "expression": _ast.get_source_segment(source, call.args[0]) if single else None,\n }\n return found\n\n\ndef _pll_returned_none(frame, lineno, tree, source):\n """How the function a `None` return came from is built: the `match` it\n ends with, and a branch of it that ends in `print`."""\n node = _pll_function_node(tree, frame.f_code, lineno)\n if node is None:\n return None\n return {\n "match": _pll_trailing_match(node, source, frame),\n "printed": _pll_print_ending_a_branch(node, source),\n }\n\n\ndef _pll_code_facts(exc, run):\n """The facts about the student\'s code that explaining `exc` needs, for\n `_pll_error_info`: `definitions`, `assigned`, and - for a function\n annotated to return something that returned `None` - `returned_none`."""\n if isinstance(exc, SyntaxError):\n return {}\n frames = _pll_student_frames(exc)\n if not frames:\n return {}\n frame, lineno = frames[-1]\n text = _pll_source_line(frame.f_code, lineno, run)\n facts = {"definitions": _pll_definitions(exc, frame, text)}\n tree, source = _pll_tree_of(frame.f_code, run)\n if tree is None:\n return facts\n names = {chain.split(".")[0] for chain in _pll_line_names(text or "")}\n facts["assigned"] = _pll_assigned(frame, lineno, tree, names)\n check = (getattr(exc, "_pll_facts", None) or {}).get("check")\n if check is not None and check["kind"] == "return" and check["actual"] in ("None", "NoneType"):\n returned = _pll_returned_none(frame, lineno, tree, source)\n if returned is not None:\n facts["returned_none"] = returned\n return facts\n';
857
192
 
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))
193
+ // src/common/bootstrap/sessions.py
194
+ var sessions_default = '# Sessions and output.\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. The host ends a session, with `_pll_end_session`, when its file\n# is closed.\n#\n# Output - text, images, tables - goes through `_pll_push`, live to the host\n# during a file run and collected otherwise, so it arrives in the order the\n# program produced it.\n\nimport contextlib\nimport json as _pll_json\nimport sys as _sys\nimport types as _pll_types\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\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 modules, keyed by session_key (e.g. document URI): a session\'s\n# globals are its module\'s `__dict__`. Created lazily; initialized from\n# `_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#: Posts what `_pll_live_emit` is holding back, set beside it.\n_pll_live_flush = 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 would cost memory and a large FFI conversion for a\n list nobody reads - a multi-million entry one, for a print loop.\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 # `print(..., flush=True)` and `sys.stdout.flush()`: a partial line,\n # a progress dot, is shown now rather than with the next line.\n flush = _pll_live_flush\n if flush is not None:\n try:\n flush()\n except Exception:\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 says {"type": "svg", ...}; promote it to the\n # 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_matplotlib_figure(value):\n """The figure a matplotlib Figure or Axes is, or None.\n\n Looked up in `sys.modules`, so a program that never imports matplotlib\n never loads it.\n """\n figures = _sys.modules.get("matplotlib.figure")\n if figures is not None and isinstance(value, figures.Figure):\n return value\n axes = _sys.modules.get("matplotlib.axes")\n if axes is not None and isinstance(value, axes.Axes):\n return value.figure\n return None\n\n\ndef _pll_is_matplotlib(value):\n """Whether `value` is one of matplotlib\'s objects, or holds one: what\n `plt.title(...)` or `plt.plot(...)` returns, which says nothing to read."""\n if isinstance(value, (list, tuple)):\n return any(_pll_is_matplotlib(item) for item in value[:20])\n return type(value).__module__.split(".")[0] == "matplotlib"\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 A matplotlib figure is shown as a picture; the lines and labels\n matplotlib\'s functions return are not printed.\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 figure = _pll_matplotlib_figure(value)\n if figure is not None:\n globals()["_pll_show_figure"](figure)\n return\n if _pll_is_matplotlib(value):\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# Called by the code `_PllTopLevelExprWrapper` adds.\n_pll_hidden("show_top_level", _pll_show_top_level)\n\n\ndef _pll_session_module(session_key):\n """Get-or-create the module for `session_key`.\n\n A new session starts with `_pll_initial_globals`, so all baseline names\n like the image primitives are present.\n """\n module = _pll_sessions.get(session_key)\n if module is None:\n module = _pll_types.ModuleType("__main__")\n module.__dict__.update(_pll_initial_globals)\n _pll_sessions[session_key] = module\n return module\n\n\ndef _pll_get_session(session_key):\n """The globals of `session_key`\'s module."""\n return _pll_session_module(session_key).__dict__\n\n\n@contextlib.contextmanager\ndef _pll_as_main(module):\n """Make `module` `__main__` while the student\'s code runs in it.\n\n A file runs as `__main__`, and whatever looks a class\'s module up -\n `typing.get_type_hints`, a dataclass\'s string annotations, `pickle`,\n `import __main__` - looks in `sys.modules`. Otherwise it finds PLL\'s\n own namespace, not the student\'s.\n """\n previous = _sys.modules.get("__main__")\n _sys.modules["__main__"] = module\n try:\n yield\n finally:\n _sys.modules["__main__"] = previous\n\n\ndef _pll_end_session(session_key):\n """Forget `session_key` and everything its runs defined: its file is closed."""\n _pll_sessions.pop(session_key, None)\n\n\ndef _pll_reset_session(session_key, level):\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. `__pll_level__` records the\n file\'s level, for the checks made while its code runs, whenever that is.\n """\n g = _pll_get_session(session_key)\n g.clear()\n g.update(_pll_initial_globals)\n g["__pll_level__"] = level\n return g\n';
861
195
 
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
- }
196
+ // src/common/bootstrap/stop.py
197
+ var stop_default = '# Stop: the host\'s interrupt, delivered as `KeyboardInterrupt` once and\n# acknowledged so the host stops re-sending it.\n\n#: The worker\'s view of the interrupt buffer, set after this file loads, or\n#: None when the host has no shared memory (and so no Stop at all). See\n#: `interruptBuffer.ts` for the layout: byte 1 is the acknowledgement.\n_pll_interrupt_view = None\n\n#: How many Stops have been delivered, ever: a `KeyboardInterrupt` raised\n#: while this did not move was raised by the program itself.\n_pll_stops_delivered = 0\n\n\ndef _pll_on_sigint(signum, frame):\n """Deliver a Stop as `KeyboardInterrupt`, once, and say it was delivered.\n\n Pyodide\'s check can overwrite a Stop that arrives at the wrong moment,\n so the host re-asserts it until it is acknowledged. Two jobs follow:\n\n - acknowledge, by setting byte 1, so the host stops re-asserting;\n - ignore a repeat of a Stop already delivered. The host can store one\n more signal just before it sees the acknowledgement, and raising it\n would land a second `KeyboardInterrupt` in PLL\'s own clean-up after\n the first, turning a clean stop into an internal error.\n\n A new press clears byte 1 first, so a program that caught the first\n `KeyboardInterrupt` and carried on can still be stopped.\n """\n global _pll_stops_delivered\n view = _pll_interrupt_view\n if view is not None:\n try:\n if view[1]:\n return\n view[1] = 1\n except Exception:\n # A buffer without the second byte: an older host. Behave as\n # Python always has.\n pass\n _pll_stops_delivered += 1\n stop = KeyboardInterrupt()\n _pll_add_facts(stop, stop=True)\n raise stop\n\n\ndef _pll_install_sigint():\n """Route SIGINT through `_pll_on_sigint`. Called once, at load."""\n try:\n import signal as _pll_signal\n\n _pll_signal.signal(_pll_signal.SIGINT, _pll_on_sigint)\n except Exception:\n # No signal support: the default handler still raises.\n pass\n\n\n_pll_install_sigint()\n\n\n#: `wait(seconds)`, true if a Stop ended it early: set after this file\n#: loads, beside `_pll_interrupt_view`, or None with no Stop to wait for.\n_pll_wait_for_stop = None\n\n\ndef _pll_install_sleep():\n """Replace `time.sleep` with one a Stop ends at once.\n\n Pyodide sees a Stop between bytecodes, and a sleep runs none, so a Stop\n during `time.sleep(10)` would wait out the ten seconds. Otherwise it is\n `time.sleep` - the same arguments, the same errors.\n """\n import operator as _pll_operator\n import time as _pll_time\n\n original = _pll_time.sleep\n\n def sleep(secs):\n wait = _pll_wait_for_stop\n if wait is None:\n return original(secs)\n if not isinstance(secs, float):\n try:\n secs = _pll_operator.index(secs)\n except TypeError:\n # Not a length of time: the original raises, in its words.\n return original(secs)\n if secs != secs:\n raise ValueError("Invalid value NaN (not a number)")\n if secs < 0:\n raise ValueError("sleep length must be non-negative")\n if wait(secs):\n # Taken here rather than at the next bytecode check, which the\n # program could reach after the line that slept.\n _pll_interrupt_view[0] = 0\n _pll_on_sigint(2, None)\n\n sleep.__doc__ = original.__doc__\n sleep.__module__ = "time"\n _pll_time.sleep = sleep\n\n\n_pll_install_sleep()\n';
885
198
 
199
+ // src/common/bootstrap/compile.py
200
+ var compile_default = '# Compiling the student\'s code: the AST passes that check top-level\n# annotations and dataclass fields, show top-level expression values, and\n# turn Python\'s compile-time warnings into something a beginner can act on.\n\nimport ast as _ast\nimport copy as _pll_copy\nimport contextlib\nimport sys as _sys\nimport warnings as _pll_warnings\nimport re as _pll_src_re\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_hint_name(hint):\n """An annotation as it was written, as near as can be recovered."""\n name = getattr(hint, "__name__", None)\n if isinstance(name, str):\n return name\n return str(hint).replace("typing.", "")\n\n\ndef _pll_swapped_field(instance, hints, field_name):\n """Another field whose value fits here, while this value fits there.\n\n `ITunesSong("Yesterday", 2015, "The Beatles")` gives `singer` a number\n and `year` a string. Advising `str(...)` for the singer would make the\n error go away and the song wrong; what happened is that two values were\n written in each other\'s places.\n """\n import typing as _pll_typing\n\n def fits(value, hint):\n origin = _pll_typing.get_origin(hint) or hint\n if not isinstance(origin, type):\n return False\n if origin is float and isinstance(value, int) and not isinstance(value, bool):\n return True\n return isinstance(value, origin) and not (\n origin is int and isinstance(value, bool)\n )\n\n mine = getattr(instance, field_name)\n for other, other_hint in hints.items():\n if other == field_name or not hasattr(instance, other):\n continue\n theirs = getattr(instance, other)\n if fits(mine, other_hint) and fits(theirs, hints[field_name]):\n return other\n return None\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_TYPEGUARD_READY:\n return cls\n memo_type = _pll_builtins.__dict__.get(_PLL_TG_MEMO)\n check = _pll_builtins.__dict__.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 try:\n check(getattr(self, field_name), [(field_name, hint)], memo)\n except _pll_type_check_error:\n # typeguard words this as an assignment ("value assigned to\n # singer"), because that is the check being reused. Nobody\n # assigned anything: a field of a value was the wrong type,\n # and the value is only known here.\n bad = getattr(self, field_name)\n shown = \'"%s"\' % bad if isinstance(bad, str) and \'"\' not in bad else repr(bad)\n swapped = _pll_swapped_field(self, cache[0], field_name)\n expected = _pll_hint_name(hint)\n error = _pll_type_check_error(\n "field %r of %r got %s (%s), not %s"\n % (field_name, cls.__name__, shown, type(bad).__name__, expected)\n )\n # The parts, as `_pll_type_check_parts` gives typeguard\'s.\n _pll_add_facts(error, check={\n "kind": "field",\n "name": field_name,\n "element": None,\n "actual": type(bad).__name__,\n "expected": [expected],\n "owner": cls.__name__,\n "value": shown,\n "level": defining_globals.get("__pll_level__"),\n })\n if swapped:\n _pll_add_facts(error, swapped_with=swapped)\n raise error from None\n\n __init__.__name__ = "__init__"\n __init__.__qualname__ = "%s.__init__" % cls.__qualname__\n # So `inspect.signature` gives the fields, not `*args, **kwargs`.\n __init__.__wrapped__ = original\n cls.__init__ = __init__\n return cls\n\n\n#: Compile-time warnings for the current file: `(line, message)`, each once.\n_pll_compile_warnings = []\n\n\n@contextlib.contextmanager\ndef _pll_recording_compile_warnings():\n """Record `SyntaxWarning`s from PLL\'s own parses and compiles instead\n of printing them.\n\n A run parses and compiles the file more than once - the type-check\n instrumentation is validated by compiling it, then the real compile\n follows - and so does importing one of the student\'s files, and Python\n prints a `SyntaxWarning` on every one.\n\n Recorded here, by file and line, and said once, after the run, by\n `_pll_say_compile_warnings`. Any other kind of warning is not PLL\'s to\n swallow, and is issued again exactly as it was.\n """\n caught = []\n try:\n with _pll_warnings.catch_warnings(record=True) as log:\n _pll_warnings.simplefilter("always", SyntaxWarning)\n try:\n yield\n finally:\n # Copied before `catch_warnings` restores the filters, so a\n # compile that raises still has its warnings kept.\n caught.extend(log)\n finally:\n for warning in caught:\n if issubclass(warning.category, SyntaxWarning):\n entry = (warning.filename, warning.lineno, str(warning.message))\n if entry not in _pll_compile_warnings:\n _pll_compile_warnings.append(entry)\n else:\n _pll_warnings.warn_explicit(\n warning.message, warning.category, warning.filename, warning.lineno\n )\n\n\n@contextlib.contextmanager\ndef _pll_without_syntax_warnings():\n """For a parse of a file that a run parses too - the static checks\',\n the test finder\'s - which the run\'s own warnings already cover."""\n with _pll_warnings.catch_warnings():\n _pll_warnings.simplefilter("ignore", SyntaxWarning)\n yield\n\n\ndef _pll_say_compile_warnings(stream, error_message, source="", filename=None):\n """Say each recorded warning once - unless the run\'s error already did.\n\n A warning that predicts the error the run then raised (`\'int\' object is\n not callable; perhaps you missed a comma?` before `TypeError: \'int\'\n object is not callable`) is covered by the finding for that error, and\n printing it beside the finding says the same thing worse. One whose\n line never ran is the only sign of the mistake, so that one is said.\n\n `source` is the text of `filename`, the file run; a warning about a\n file it imported names that file.\n """\n lines = source.split("\\n") if source else []\n for where, lineno, message in _pll_compile_warnings:\n if error_message and message.startswith(error_message):\n continue\n if filename is None or where == filename:\n text = lines[lineno - 1] if isinstance(lineno, int) and 0 < lineno <= len(lines) else ""\n stream.write("warning: line %s: %s\\n" % (lineno, _pll_reword_warning(message, text)))\n else:\n stream.write(\n "warning: %s, line %s: %s\\n" % (_pll_shown_file(where), lineno, _pll_reword_warning(message))\n )\n del _pll_compile_warnings[:]\n\n\ndef _pll_reword_warning(message, line=""):\n """Python\'s wording, except where it points the wrong way.\n\n "\'int\' object is not callable; perhaps you missed a comma?" is Python\'s\n guess for `3(width)`, and for a number the guess is wrong: what is\n missing is a `*`. For a string or a tuple the comma guess is usually\n right - a list of rows with one comma left out - so those stay.\n\n The example is taken from the student\'s own line when it can be read,\n rather than one fixed example shown whatever they wrote.\n """\n if not _pll_src_re.match(\n r"\'(int|float)\' object is not callable; perhaps you missed a comma\\?", message\n ):\n return message\n written = _pll_src_re.search(r"(\\d+(?:\\.\\d+)?)\\s*\\(([^()]*)\\)", line)\n if written is None:\n return (\n "brackets after a number are a function call, not multiplication. "\n "To multiply, write a `*` between them."\n )\n number, inside = written.group(1), written.group(2).strip()\n return (\n "brackets after a number are a function call, not multiplication. "\n "To multiply, write the `*`: `%s * %s`, not `%s`."\n % (number, inside or "...", written.group(0))\n )\n\n\ndef _pll_parse_and_instrument(code, filename, level):\n """Parse `code`, adding the runtime type checks of its `level` when they\n are available.\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 with _pll_recording_compile_warnings():\n tree = _ast.parse(code, filename=filename, mode="exec")\n if not _pll_checks_annotations(level) or not _PLL_TYPEGUARD_READY:\n return tree\n try:\n instrumented = _ast.parse(code, filename=filename, mode="exec")\n own = {id(node) for node in _ast.walk(instrumented) if isinstance(node, _ast.ImportFrom)}\n _pll_typeguard_transformer().visit(instrumented)\n _pll_hide_typeguard_imports(instrumented, own)\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\ndef _pll_hide_typeguard_imports(tree, own):\n """Take typeguard\'s own imports out of the student\'s namespace.\n\n Its instrumentation imports its checkers into the module it checks\n (`TypeCheckMemo`, `check_argument_types_internal`), where `dir()` listed\n them and an assignment could replace one. Each is bound among the\n builtins instead (`_pll_hidden`), and the code that calls it renamed to\n match. `own` is the imports the tree had before typeguard added any,\n the student\'s, which are left alone.\n """\n import importlib as _pll_importlib\n\n renamed = {}\n for node in _ast.walk(tree):\n body = getattr(node, "body", None)\n if not isinstance(body, list):\n continue\n kept = []\n for stmt in body:\n if (\n isinstance(stmt, _ast.ImportFrom)\n and id(stmt) not in own\n and stmt.level == 0\n and (stmt.module or "").split(".")[0] == "typeguard"\n ):\n module = _pll_importlib.import_module(stmt.module)\n for alias in stmt.names:\n renamed[alias.asname or alias.name] = _pll_hidden(\n "tg:" + alias.name, getattr(module, alias.name)\n )\n continue\n kept.append(stmt)\n node.body = kept or [_ast.Pass()]\n if renamed:\n for node in _ast.walk(tree):\n if isinstance(node, _ast.Name) and node.id in renamed:\n node.id = renamed[node.id]\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';
886
201
 
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
- }
202
+ // src/common/bootstrap/libraryHelpers.py
203
+ var libraryHelpers_default = '# Helpers the libraries share: ordinals, describing a value, numbers, the\n# closest name to a misspelling, and reading a source that is either a URL\n# or a file next to the program.\n#\n# They live here because the libraries are exec\'d into these globals\n# afterwards, so "https:// means the network, anything else means a file" is\n# decided in exactly one place and every library reports the same way when\n# it goes wrong.\n\nimport numbers as _pll_src_numbers\nimport re as _pll_src_re\n\n_PLL_ORDINALS = ("1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th")\n\n\ndef _pll_ordinal(index):\n """`1st`, `2nd`, ... for a 0-based position.\n\n Used instead of the index itself wherever a message would otherwise\n have to say "Row 1" about the second row, or "argument 0".\n """\n if index < len(_PLL_ORDINALS):\n return _PLL_ORDINALS[index]\n return "%dth" % (index + 1)\n\n\ndef _pll_xml_escape(text):\n """`text` as it can appear in SVG, as content or inside an attribute."""\n return (\n str(text)\n .replace("&", "&amp;")\n .replace("<", "&lt;")\n .replace(">", "&gt;")\n .replace(\'"\', "&quot;")\n .replace("\'", "&apos;")\n )\n\n\ndef _pll_describe(value):\n """A value as a student would name it, for a message about it.\n\n One copy, here, because the image and table libraries are exec\'d into\n these same globals: two definitions meant the second silently replaced\n the first, and whichever lost its turn stopped recognising its own\n types. An image was then described as `a _Rectangle`, naming a class\n nobody wrote.\n\n Images and tables are recognised by the same duck-typing the display\n code uses, so the bootstrap still does not depend on either library.\n """\n if value is None:\n return "None"\n if isinstance(value, bool):\n return "%s" % value\n if isinstance(value, str):\n return \'the string "%s"\' % value\n if isinstance(value, _pll_src_numbers.Real):\n return "the number %s" % _pll_number(value)\n # Looked up when asked: the image library loads after this file.\n image_type = globals().get("Image")\n if isinstance(image_type, type) and isinstance(value, image_type):\n return "an image"\n if hasattr(value, "_pll_table_data"):\n return "a table"\n if hasattr(value, "_pll_image_data"):\n # Shown as a picture, and not one: a reactor.\n return "a reactor"\n if isinstance(value, dict):\n return "a row" if type(value).__name__ == "Row" else "a dictionary"\n if isinstance(value, (list, tuple)):\n return "a list of %d" % len(value)\n if callable(value):\n return "the function `%s`" % getattr(value, "__name__", "given")\n name = type(value).__name__\n # A private class is PLL\'s own; a student has no name for it but the\n # thing it is.\n if name.startswith("_"):\n return "a value"\n return "%s %s" % ("an" if name[:1].lower() in "aeiou" else "a", name)\n\n\ndef _pll_number(value):\n """`20`, not `20.0`, for a number in a message; `nan` and `inf` as Python writes them."""\n if isinstance(value, float):\n if value.is_integer() and abs(value) < 1e16:\n return "%d" % int(value)\n return repr(float(value))\n return "%s" % value\n\n\ndef _pll_edit_distance(a, b):\n """Edit distance, counting a swap of two neighbours as one mistake.\n\n Plain Levenshtein charges two for `yaer` -> `year`, which is enough to\n push the commonest typo of all past any threshold tight enough to be\n useful. Lives here because the bootstrap is loaded before the image,\n table and reactor libraries, all of which suggest a name the student\n probably meant.\n """\n previous = list(range(len(b) + 1))\n two_back = []\n for i in range(1, len(a) + 1):\n current = [i] + [0] * len(b)\n for j in range(1, len(b) + 1):\n current[j] = min(\n previous[j] + 1,\n current[j - 1] + 1,\n previous[j - 1] + (0 if a[i - 1] == b[j - 1] else 1),\n )\n if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:\n current[j] = min(current[j], two_back[j - 2] + 1)\n two_back = previous\n previous = current\n return previous[len(b)]\n\n\ndef _pll_closest_name(name, candidates):\n """The candidate `name` was probably meant to be, or None.\n\n Close enough to be a misspelling rather than a different word: a third\n of the name\'s length, which covers `yaer` and `outilne` without\n turning an unrelated word into a confident guess.\n """\n if not isinstance(name, str):\n return None\n best = None\n best_distance = None\n # Sorted, so two candidates the same distance away always give the same\n # answer: a set\'s own order is arbitrary and can differ between runs.\n for candidate in sorted(candidates):\n distance = _pll_edit_distance(name.lower(), candidate.lower())\n if best_distance is None or distance < best_distance:\n best = candidate\n best_distance = distance\n if best is None:\n return None\n return best if best_distance <= max(1, len(best) // 3) else None\n\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 `XMLHttpRequest` with an `arraybuffer` response, so the bytes arrive as\n they were sent, picture or text, in a browser\'s worker and in the\n desktop\'s polyfill alike. Synchronous XHR is fine here: Pyodide runs in\n a worker, never on a page\'s main thread.\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 desktop = getattr(_Xhr, "pllNode", False)\n view = globals().get("_pll_interrupt_view")\n xhr = _Xhr.new()\n failure = None\n try:\n xhr.open("GET", url, False)\n xhr.responseType = "arraybuffer"\n if desktop and view is not None:\n # So a Stop ends the wait for the answer.\n xhr.pllInterrupt = view\n xhr.send(None)\n except Exception as e:\n failure = (getattr(e, "name", ""), str(getattr(e, "message", "") or e).split("\\n")[0])\n if failure is not None:\n kind, reason = failure\n if kind == "AbortError":\n # Outside the `except`: raised from the JavaScript error, the\n # Stop would be placed in PLL\'s worker.\n view[0] = 0\n _pll_on_sigint(2, None)\n if desktop:\n # The desktop\'s XHR says what went wrong, in a sentence.\n raise OSError("%s could not read %s: %s." % (what, url, reason))\n # In a browser a failed cross-origin request looks like this, and it\n # is the likeliest cause by far.\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, reason)\n )\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(xhr.response.to_bytes())\n\n\n#: Files beside the program that were kept back when it was given its\n#: files, by name, with why (`_pll_note_left_out`, at every mount) - so\n#: reading one says that, rather than that there is no such file.\n_pll_left_out = {}\n\n\ndef _pll_note_left_out(left_out_json):\n _pll_left_out.clear()\n for entry in _pll_json.loads(left_out_json):\n _pll_left_out[entry["name"]] = entry["why"]\n\n\n#: The files already said to be in another encoding, this run.\n_pll_encoding_notes = set()\n\n\ndef _pll_decode_text(data, source, what):\n """The text in `data`, as a spreadsheet saves it.\n\n A byte order mark is dropped - Excel\'s "CSV UTF-8" starts with one, and\n left in it becomes part of the first column\'s name. A file that is not\n UTF-8 is read as Windows-1252, which is what Excel\'s plain "CSV" is, and\n a note says so once.\n """\n if data.startswith(b"\\xef\\xbb\\xbf"):\n data = data[3:]\n elif data.startswith((b"\\xff\\xfe", b"\\xfe\\xff")):\n try:\n return data.decode("utf-16")\n except UnicodeDecodeError:\n pass\n try:\n return data.decode("utf-8")\n except UnicodeDecodeError:\n pass\n if b"\\x00" in data[:4096]:\n raise ValueError(\n "%s could not read %r as text - it does not look like a text file."\n % (what, source)\n )\n if source not in _pll_encoding_notes:\n _pll_encoding_notes.add(source)\n print(\n "note: %s is not UTF-8, so it was read as Windows-1252, the "\n "encoding Excel saves a plain CSV in. If some letters look wrong, "\n \'save it again as "CSV UTF-8".\' % source,\n file=_sys.stderr,\n )\n return data.decode("cp1252", errors="replace")\n\n\ndef _pll_nearby_files(wanted):\n """" The files here are: cars.csv, trips.csv.", when there are any.\n\n A missing file is usually a misspelling or a file in another folder,\n and both are obvious the moment the actual names are in front of you.\n Only files with the same extension are listed, so asking for a CSV\n does not produce a directory listing of the whole project.\n """\n import os as _pll_os\n\n folder = _pll_os.path.dirname(wanted) or "."\n _, extension = _pll_os.path.splitext(wanted)\n try:\n names = sorted(\n name\n for name in _pll_os.listdir(folder)\n if not extension or name.lower().endswith(extension.lower())\n )\n except OSError:\n return ""\n if not names:\n return ""\n # A misspelling is the usual reason, and then one name is the answer.\n close = _pll_closest_name(_pll_os.path.basename(wanted), names)\n if close is not None:\n return \' Did you mean "%s"?\' % close\n shown = names[:8]\n label = extension.lstrip(".").upper() + " " if extension else ""\n return " The %sfiles next to your program are: %s%s." % (\n label,\n ", ".join(shown),\n ", ..." if len(names) > len(shown) else "",\n )\n\n\ndef _pll_read_source(source, what, binary=False, limit=None):\n """Read `source` - a URL or a path beside the program - and return it.\n\n Returns bytes when `binary`, otherwise text (`_pll_decode_text`). More\n than `limit` bytes is refused, from a file or an address alike.\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 why = _pll_left_out.get(stripped.lstrip("./"))\n if why is not None:\n raise FileNotFoundError(\n \'"%s" is next to your program, but it was not loaded: %s.\'\n % (stripped, why)\n ) from None\n nearby = _pll_nearby_files(stripped)\n raise FileNotFoundError(\n \'There is no file called "%s" next to your program.%s\'\n % (\n stripped,\n # A close name answers it; otherwise, say what to check.\n nearby\n if nearby.startswith(" Did you mean")\n else nearby + " Check the spelling, or pass an https:// address instead.",\n )\n ) from None\n except IsADirectoryError:\n raise IsADirectoryError(\'"%s" is a folder, not a file.\' % stripped) from None\n if limit is not None and len(data) > limit:\n raise ValueError(\n "%s reads at most %d MB, and %r is %.1f MB."\n % (what, limit // (1024 * 1024), source, len(data) / (1024 * 1024))\n )\n if binary:\n return data\n return _pll_decode_text(data, source, what)\n';
896
204
 
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
205
+ // src/common/bootstrap/packages.py
206
+ var packages_default = '# The packages a program needs, found before it runs, and what to say when\n# an import still fails.\n#\n# Pyodide has to download a package before Python can import it, and a\n# download cannot happen in the middle of a run, so the host loads them\n# first (`loadPackages` in workerHost.ts), from what `_pll_package_imports`\n# finds here: every import, wherever it is in the program or in the\n# student\'s files beside it, and the modules named to `importlib` or\n# `__import__` in so many words.\n\nimport ast as _pll_pkg_ast\nimport json as _pll_pkg_json\nimport sys as _pll_pkg_sys\n\n#: The top-level names the last run\'s files import, as found before it ran.\n_pll_packages_requested = set()\n\n#: The modules that read URLs: when one is imported, PLL routes urllib and\n#: requests through the host\'s network (`http.py`). pandas, because its\n#: readers take URLs.\n_PLL_NETWORK_MODULES = frozenset(\n ("pandas", "requests", "urllib", "urllib3", "httpx", "aiohttp", "http")\n)\n\n#: Never loaded for a program: CPython\'s own tests, which Pyodide ships as\n#: a package called `test` - and which would hide a student\'s `test.py`.\n_PLL_NEVER_LOADED = frozenset(("test",))\n\n\ndef _pll_own_modules(siblings):\n """The student\'s `.py` files by the top-level module they make: `helper`\n for `helper.py`, `shapes` for every file under `shapes/`."""\n own = {}\n for sibling in siblings:\n if sibling["name"].endswith(".py"):\n own.setdefault(sibling["name"][:-3].split("/")[0], []).append(sibling["text"])\n return own\n\n\ndef _pll_imported_names(code):\n """The top-level modules `code` imports, by any statement or a literal name."""\n try:\n # Quietly: the run parses the file too, and says its warnings once.\n with _pll_without_syntax_warnings():\n tree = _pll_pkg_ast.parse(code)\n except (SyntaxError, ValueError):\n return set()\n names = set()\n for node in _pll_pkg_ast.walk(tree):\n if isinstance(node, _pll_pkg_ast.Import):\n names.update(alias.name.split(".")[0] for alias in node.names)\n elif isinstance(node, _pll_pkg_ast.ImportFrom):\n if node.level == 0 and node.module:\n names.add(node.module.split(".")[0])\n elif isinstance(node, _pll_pkg_ast.Call) and node.args:\n func = node.func\n called = func.id if isinstance(func, _pll_pkg_ast.Name) else (\n func.attr if isinstance(func, _pll_pkg_ast.Attribute) else None\n )\n first = node.args[0]\n if (\n called in ("import_module", "__import__")\n and isinstance(first, _pll_pkg_ast.Constant)\n and isinstance(first.value, str)\n and first.value\n ):\n names.add(first.value.split(".")[0])\n return names\n\n\ndef _pll_package_imports(code, siblings_json):\n """What to load before `code` runs, as `{"modules": [...], "network": bool}`.\n\n `siblings_json` is the student\'s other `.py` files, `[{name, text}]`.\n The imports of those the program imports count too, and theirs in turn\n - but not of a file it never imports, another program in the folder.\n Their own names are not packages.\n """\n own = _pll_own_modules(_pll_pkg_json.loads(siblings_json))\n names = set()\n pending = [code]\n visited = set()\n while pending:\n found = _pll_imported_names(pending.pop())\n names |= found\n for module in found:\n if module in own and module not in visited:\n visited.add(module)\n pending.extend(own[module])\n names -= set(own)\n names -= _PLL_NEVER_LOADED\n _pll_packages_requested.clear()\n _pll_packages_requested.update(names)\n return {\n "modules": sorted(names),\n "network": bool(names & _PLL_NETWORK_MODULES),\n }\n\n\n_pll_import_packages = None\n\n\ndef _pll_pyodide_package(module):\n """The Pyodide package that provides `module`, or None."""\n global _pll_import_packages\n if _pll_import_packages is None:\n try:\n import pyodide_js\n from js import JSON\n\n packages = _pll_pkg_json.loads(JSON.stringify(pyodide_js.lockfile.packages))\n except Exception:\n packages = {}\n _pll_import_packages = {}\n for package, info in packages.items():\n for name in info.get("imports", []):\n _pll_import_packages[name] = package\n return _pll_import_packages.get(module)\n\n\ndef _pll_package_loaded(package):\n try:\n import pyodide_js\n\n return package in pyodide_js.loadedPackages.to_py()\n except Exception:\n return False\n\n\ndef _pll_enrich_module_not_found(exc):\n """Say why a top-level import found nothing (`module`).\n\n - `missing`: Pyodide has no such package, and it is not one of the\n student\'s files - perhaps one misspelt (`close`);\n - `leftOut`: it is one of their files, and a limit kept it back (`why`);\n - `notLoaded`: Pyodide has it, PLL asked for it, and it did not load -\n which takes the internet the first time;\n - `notSeen`: Pyodide has it, and no import PLL could read before the\n run named it.\n """\n if type(exc) is not ModuleNotFoundError or not isinstance(getattr(exc, "name", None), str):\n return\n module = exc.name.split(".")[0]\n if module != exc.name or module in _pll_pkg_sys.stdlib_module_names:\n # A part of a package that is there, or Python\'s own: Python\'s\n # message is the whole story.\n return\n why = _pll_left_out.get(module + ".py")\n if why is not None:\n _pll_add_facts(exc, module={"name": module, "kind": "leftOut", "why": why})\n return\n package = _pll_pyodide_package(module) if module not in _PLL_NEVER_LOADED else None\n if package is None:\n import os as _pll_pkg_os\n\n # Not a file the import is in: the program does not import itself.\n running = {\n _pll_pkg_os.path.basename(frame.f_code.co_filename) for frame, _ in _pll_student_frames(exc)\n }\n try:\n files = [n[:-3] for n in _pll_pkg_os.listdir(".") if n.endswith(".py") and n not in running]\n except OSError:\n files = []\n _pll_add_facts(\n exc,\n module={"name": module, "kind": "missing", "close": _pll_closest_name(module, files)},\n )\n return\n if module not in _pll_packages_requested:\n kind = "notSeen"\n elif not _pll_package_loaded(package):\n kind = "notLoaded"\n else:\n return\n _pll_add_facts(exc, module={"name": module, "kind": kind, "package": package})\n';
908
207
 
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)
208
+ // src/common/bootstrap/running.py
209
+ var running_default = '# Running: a whole file, a prompt line, and whether a prompt line is\n# complete yet. Each returns a JSON-friendly dict.\n\nimport ast as _ast\nimport codeop as _codeop\nimport contextlib\nimport os as _pll_os\n\n# -----------------------------------------------------------------------------\n# Ending a program\n# -----------------------------------------------------------------------------\n\n\nclass _PllExit(SystemExit):\n """`os._exit()` or `os.abort()`, ending the program and nothing more.\n\n In CPython each ends the whole process at once. Here the process is the\n one interpreter every open file\'s session shares, and Pyodide does not\n recover from it: every later run, of any file, would fail with "Pyodide\n already exited" until the window is reloaded. Raised instead, they end\n the program the way `sys.exit()` does, and Python carries on.\n """\n\n\ndef _pll_os_exit(status):\n raise _PllExit(status)\n\n\ndef _pll_os_abort():\n raise _PllExit(134)\n\n\ndef _pll_exit_status(exc, stderr):\n """The status a process ending with `exc` exits with, as CPython decides.\n\n `sys.exit()` is 0 and `sys.exit(3)` is 3. Anything else, such as\n `sys.exit("no data file")`, is written to stderr - the student\'s message\n is the point of writing it that way - and the status is 1.\n """\n code = exc.code\n if code is None:\n return 0\n if isinstance(code, int):\n # `int`, so `SystemExit(True)` is 1 rather than crossing to the host\n # as a boolean.\n code = int(code)\n if not -(2**63) <= code < 2**63:\n # Too big for the C long CPython gives `exit()`.\n return 255\n # The low byte, which is the status, once it is too big to cross to\n # the host as a number.\n return code if abs(code) < 2**53 else code & 0xFF\n stderr.write("%s\\n" % (code,))\n return 1\n\n\n# Their own names, so a mistake in calling one reads as it would in Python.\n_pll_os_exit.__name__ = _pll_os_exit.__qualname__ = "_exit"\n_pll_os_abort.__name__ = _pll_os_abort.__qualname__ = "abort"\n_pll_os._exit = _pll_os_exit\n_pll_os.abort = _pll_os_abort\n# `os` takes both from `posix`, which a program can call directly.\nimport posix as _pll_posix\n\n_pll_posix._exit = _pll_os_exit\n_pll_posix.abort = _pll_os_abort\n\n# -----------------------------------------------------------------------------\n# REPL syntax check (codeop.compile_command in \'single\' mode)\n# -----------------------------------------------------------------------------\n\ndef _pll_repl_check(source, whole=False):\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 `whole` is for several lines submitted at once, judged as a whole in\n \'exec\' mode - so a blank line inside a function does not end it, and\n statements one after another are not "multiple statements".\n """\n try:\n result = _codeop.compile_command(source, "<repl>", "exec" if whole else "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_reset_notes():\n """Forget what the last run had to say, at its end or along the way."""\n _pll_encoding_notes.clear()\n reset = globals().get("_pll_reset_reactor_notes")\n if reset is not None:\n reset()\n\n\ndef _pll_run_notes():\n """What is worth saying once the program has finished, as stderr text.\n\n Not an error: the program ran. It is the case where it ran and visibly\n did nothing, and the student has no other evidence of why - today, a\n reactor that was built and never started. Looked up rather than named\n so the bootstrap does not depend on the reactor library, which loads\n after it.\n """\n note = globals().get("_pll_reactor_note")\n if note is None:\n return ""\n try:\n return note() or ""\n except Exception:\n # A note is a courtesy; it must never take the run down with it.\n return ""\n\n\ndef _pll_no_error():\n """The error fields of a result that reports none."""\n return {\n "error_type": None,\n "error_message": None,\n "traceback": None,\n "error_file": None,\n "line_number": None,\n "column": None,\n "error_frames": [],\n "error_facts": {},\n }\n\n\ndef _pll_run_result():\n """A file run\'s or prompt line\'s result, before anything has happened.\n\n `exit_code` is set only when the program ended itself, with `sys.exit`;\n `tests` only when its tests were asked for and it finished, so they ran;\n `stopped` when a Stop reached it, even one it caught.\n """\n result = {\n "ok": False,\n "stdout": "",\n "stderr": "",\n "result_repr": None,\n "displays": [],\n "exit_code": None,\n "tests": None,\n "stopped": False,\n }\n result.update(_pll_no_error())\n return result\n\n\ndef _pll_with_output(result, stdout, stderr):\n """`result`, with what the run printed and showed."""\n result["stdout"] = stdout.getvalue()\n result["stderr"] = stderr.getvalue()\n result["displays"] = list(_pll_displays)\n return result\n\n\ndef _pll_stopped_run():\n """A run\'s result when Stop landed before any of the student\'s code ran."""\n result = _pll_run_result()\n result.update(error_type="KeyboardInterrupt", error_message="")\n return result\n\n\ndef _pll_stoppable(stopped):\n """Report a Stop that lands while PLL prepares the file as a Stop.\n\n A Stop is retried until Python takes it, so one pressed just as a run\n begins can be taken while the file is still being parsed and\n instrumented - outside the `except` that reports a Stop in the\n student\'s code. From there it escaped as an internal error. `stopped()`\n is the result to give instead: none of the student\'s code has run, so\n there is no output and no line to report.\n """\n def decorate(fn):\n def run(*args, **kwargs):\n try:\n return fn(*args, **kwargs)\n except KeyboardInterrupt:\n return stopped()\n run.__name__ = fn.__name__\n run.__doc__ = fn.__doc__\n return run\n return decorate\n\n\n@_pll_stoppable(_pll_stopped_run)\ndef _pll_run_file(code, filename, session_key, level=_PLL_LEVEL_RAW, run_tests=False):\n """Run a file as its program, and then - if asked, and if it finished -\n its own `test_*` functions, against the names the program defined.\n\n The file is executed once: the tests run in the program\'s namespace,\n after its top-level code. `run_tests` needs pytest loaded, for its\n assertion rewriting.\n """\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = _pll_run_result()\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_protect_import_path()\n user_globals = _pll_reset_session(session_key, level)\n # Where the file is beside the files it was given, so\n # `os.path.dirname(__file__)` finds them.\n user_globals["__file__"] = _PLL_WORK_DIR + "/" + filename\n main = _pll_session_module(session_key)\n _pll_displays.clear()\n _pll_reset_notes()\n del _pll_compile_warnings[:]\n try:\n tree = _pll_parse_and_instrument(code, filename, level)\n # Where each test is, read before anything is added to the tree.\n tests_at = _pll_test_locations(tree) if run_tests else None\n # The levels students write show a top-level expression\'s value;\n # `raw` is Python, which does not.\n if level != _PLL_LEVEL_RAW:\n _PllTopLevelExprWrapper().visit(tree)\n _ast.fix_missing_locations(tree)\n if run_tests:\n _pll_rewrite_asserts(tree, code, filename)\n with _pll_recording_compile_warnings():\n compiled = compile(tree, filename, "exec")\n except SyntaxError as e:\n result.update(_pll_error_info(e, (filename, code)))\n return _pll_with_output(result, stdout, stderr)\n\n finished = False\n stops = _pll_stops_delivered\n try:\n with _pll_as_main(main), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n exec(compiled, user_globals)\n result["ok"] = True\n finished = True\n except SystemExit as e:\n result["ok"] = True\n result["exit_code"] = _pll_exit_status(e, stderr)\n except BaseException as e:\n result.update(_pll_error_info(e, (filename, code)))\n finally:\n # After the run, so a warning the run\'s own error explains can be\n # left out, and one about a line that never ran can be said.\n _pll_say_compile_warnings(stderr, result["error_message"], code, filename)\n _pll_with_output(result, stdout, stderr)\n # A program that raised, stopped or exited did not get to the end, and\n # neither do its tests: the host says they were not run, and why. A Stop\n # the program caught still ends the run.\n if run_tests and finished and _pll_stops_delivered == stops:\n with _pll_as_main(main):\n result["tests"] = _pll_run_collected_tests(user_globals, tests_at, (filename, code))\n result["stopped"] = _pll_stops_delivered != stops\n if finished:\n # Only once the program and its tests have finished: building a\n # reactor and starting it further down is perfectly ordinary, and a\n # test that steps one has used it.\n stderr.write(_pll_run_notes())\n _pll_with_output(result, stdout, stderr)\n return result\n\n\n# -----------------------------------------------------------------------------\n# REPL-style eval (statements + last-expression value)\n# -----------------------------------------------------------------------------\n\n@_pll_stoppable(_pll_stopped_run)\ndef _pll_repl_eval(code, session_key, level=_PLL_LEVEL_RAW):\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n result = _pll_run_result()\n _pll_displays.clear()\n _pll_protect_import_path()\n user_globals = _pll_get_session(session_key)\n user_globals["__pll_level__"] = level\n main = _pll_session_module(session_key)\n filename = "<repl>"\n del _pll_compile_warnings[:]\n try:\n tree = _pll_parse_and_instrument(code, filename, level)\n except SyntaxError as e:\n result.update(_pll_error_info(e))\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 _pll_as_main(main), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):\n if tree.body:\n with _pll_recording_compile_warnings():\n compiled_stmts = compile(tree, filename, "exec")\n _pll_remember_prompt_source(compiled_stmts, code)\n exec(compiled_stmts, user_globals)\n if last_expr is not None:\n expr_module = _ast.Expression(body=last_expr.value)\n with _pll_recording_compile_warnings():\n compiled_expr = compile(expr_module, filename, "eval")\n _pll_remember_prompt_source(compiled_expr, code)\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 as e:\n result["ok"] = True\n result["exit_code"] = _pll_exit_status(e, stderr)\n except BaseException as e:\n result.update(_pll_error_info(e))\n finally:\n _pll_say_compile_warnings(stderr, result["error_message"], code, filename)\n _pll_with_output(result, stdout, stderr)\n return result\n';
911
210
 
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))
211
+ // src/common/bootstrap/tests.py
212
+ var tests_default = '# Tests: the file\'s own `test_*` functions and `Test*` classes, collected\n# the way pytest collects them from the namespace the program made, and run\n# one at a time once it has finished - not `pytest.main()`, which cannot\n# safely be called repeatedly in one interpreter. pytest is used only to\n# rewrite assertions (`_pll_rewrite_asserts`, applied by `_pll_run_file`).\n\nimport traceback as _tb_mod\nimport ast as _ast\nimport contextlib\nimport re as _pll_src_re\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 with _pll_without_syntax_warnings():\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\n#: A "where" line that only says what a function is: `<function approx\n#: at 0x1177>` is not something to read.\n_PLL_FUNCTION_WHERE_RE = _pll_src_re.compile(\n r"^(?:where|and)\\s+(<(?:function|built-in function|bound method|built-in method|class) [^\\n]*?>) = (.+)$"\n)\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 # pytest says what each name in the assert is; for a function that is\n # its address. The name it was written as says more, so it stands in.\n for line in list(lines):\n found = _PLL_FUNCTION_WHERE_RE.match(line)\n if found is not None:\n lines.remove(line)\n lines = [other.replace(found.group(1), found.group(2)) for other in lines]\n if lines:\n return "\\n".join(lines) + _pll_float_note("\\n".join(lines))\n for raw in reversed((tb_text or "").splitlines()):\n stripped = raw.strip()\n if stripped.startswith("assert "):\n return stripped + _pll_float_note(stripped)\n return "This test failed."\n\n\n#: Two decimal numbers in a failed `assert ... == ...`.\n_PLL_FLOAT_PAIR_RE = _pll_src_re.compile(\n r"(-?\\d+\\.\\d+(?:e[-+]?\\d+)?)\\s*==\\s*(-?\\d+(?:\\.\\d+)?(?:e[-+]?\\d+)?)"\n)\n\n\ndef _pll_float_note(text):\n """" ... use pytest.approx", when two numbers differ only in the dust.\n\n `0.9299999999999999 == 0.93` is how floating point works, not a bug in\n the student\'s arithmetic, and nothing in the failure says so.\n """\n match = _PLL_FLOAT_PAIR_RE.search(text)\n if match is None:\n return ""\n try:\n left = float(match.group(1))\n right = float(match.group(2))\n except ValueError:\n return ""\n if left == right:\n return ""\n scale = max(abs(left), abs(right), 1e-12)\n if abs(left - right) / scale > 1e-6:\n return ""\n return (\n "\\nThese differ only in the last few digits, which is how decimals "\n "work in any computer. Compare them with `pytest.approx`: "\n "`assert value == pytest.approx(%s)`." % match.group(2)\n )\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_marks(fn):\n """The pytest marks on a test: `[(name, args, kwargs)]`."""\n marks = []\n for mark in getattr(fn, "pytestmark", None) or []:\n name = getattr(mark, "name", None)\n if isinstance(name, str):\n marks.append((name, tuple(getattr(mark, "args", ())), dict(getattr(mark, "kwargs", {}))))\n return marks\n\n\ndef _pll_mark_condition(args, kwargs, fn):\n """Whether a `skipif` / `xfail` mark\'s condition holds: none is true; a\n string is evaluated in the test\'s module, as pytest does. A method\'s\n module is the one `_pll_iter_tests` noted."""\n conditions = args if args else ((kwargs["condition"],) if "condition" in kwargs else ())\n if not conditions:\n return True\n for condition in conditions:\n if isinstance(condition, str):\n try:\n module = getattr(fn, "pll_module", None) or getattr(fn, "__globals__", {})\n condition = eval(condition, module)\n except Exception:\n condition = False\n if condition:\n return True\n return False\n\n\ndef _pll_call_test(fn, run=None):\n """Run one test function, as pytest would - its `skip`, `skipif` and\n `xfail` marks included.\n\n Returns `(outcome, message, stdout, error)`. `error` is the\n `_pll_error_info` of a test that raised, for the host\'s explanations;\n the message is the one line a report has room for. A test expected to\n fail that does is "skipped", saying so; one that passes anyway passes,\n unless its mark is `strict`.\n """\n expected_failure = None\n for name, args, kwargs in _pll_marks(fn):\n reason = kwargs.get("reason") or (args[0] if name == "skip" and args else None)\n if name == "skip" or (name == "skipif" and _pll_mark_condition(args, kwargs, fn)):\n return ("skipped", reason if isinstance(reason, str) and reason else None, None, None)\n if name == "xfail" and _pll_mark_condition(args, kwargs, fn):\n expected_failure = (reason if isinstance(reason, str) else "", bool(kwargs.get("strict")))\n outcome = _pll_call_test_body(fn, run)\n if expected_failure is not None:\n reason, strict = expected_failure\n if outcome[0] in ("failed", "error"):\n said = "expected to fail" + (": " + reason if reason else "") + ", and did."\n return ("skipped", said, outcome[2], None)\n if outcome[0] == "passed" and strict:\n said = "expected to fail" + (": " + reason if reason else "") + ", but passed."\n return ("failed", said, outcome[2], None)\n return outcome\n\n\ndef _pll_call_test_body(fn, run):\n import io\n\n buf = io.StringIO()\n stops = _pll_stops_delivered\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 None,\n )\n fn()\n return ("passed", None, buf.getvalue().strip() or None, None)\n except KeyboardInterrupt as e:\n # A Stop, not something this test did wrong: it ends the tests, so\n # it is not recorded as this one\'s error and passed over. One the\n # test raised itself is its error, like any other.\n if _pll_stops_delivered != stops:\n raise\n return _pll_test_error(e, buf, run)\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 None,\n )\n except BaseException as e:\n name = type(e).__name__\n if name == "Skipped":\n return (\n "skipped",\n str(e).strip() or None,\n buf.getvalue().strip() or None,\n None,\n )\n if name == "XFailed":\n # `pytest.xfail("reason")`: the test says it is expected to fail.\n reason = str(e).strip()\n return (\n "skipped",\n "expected to fail" + (": " + reason if reason else "") + ".",\n buf.getvalue().strip() or None,\n None,\n )\n return _pll_test_error(e, buf, run)\n\n\ndef _pll_test_error(e, buf, run):\n """The outcome of a test that raised `e`, which was not an assertion."""\n return (\n "error",\n type(e).__name__ + ": " + (_pll_linux_errno(str(e), e) or "this test raised an exception."),\n buf.getvalue().strip() or None,\n _pll_error_info(e, run),\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 # pytest reads marks from the method and from its class.\n _bound.pytestmark = list(getattr(attr, "pytestmark", None) or []) + list(\n getattr(obj, "pytestmark", None) or []\n )\n _bound.pll_module = getattr(attr, "__globals__", None)\n yield name + "::" + meth_name, _bound\n\n\ndef _pll_rewrite_asserts(tree, code, filename):\n """Rewrite `tree`\'s asserts the way pytest does, so a failure says\n `assert 4 == 5` rather than nothing. In place; if it fails, the file\n still runs, with plain `AssertionError`s.\n """\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n\n rewrite_asserts(tree, code.encode("utf-8"), module_path=filename)\n import _pytest.assertion.util as _pll_assert_util\n\n _pll_assert_util._reprcompare = _pll_reprcompare\n # Do not call ast.fix_missing_locations after this: 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 except Exception:\n # It mutates the tree as it goes, so what is compiled is whatever it\n # managed; a failure then shows an empty AssertionError, which is\n # worse than nothing but still runs.\n pass\n\n\ndef _pll_reprcompare(op, left, right):\n """What a failed comparison adds, as pytest\'s plugins add it: for two\n tables, where they differ, which their rows alone do not show.\n\n Looked up when called: the table library loads after this file.\n """\n differences = globals().get("_pll_table_differences")\n if op != "==" or differences is None:\n return None\n try:\n lines = differences(left, right)\n except Exception:\n return None\n if not lines:\n return None\n lines = ["%r == %r" % (left, right)] + lines\n # In pytest\'s own form: "~" continues the explanation, "%" is escaped.\n return "\\n~".join(line.replace("\\n", "\\\\n") for line in lines).replace("%", "%%")\n\n\ndef _pll_tests_result():\n """The tests\' part of a run\'s result, before any has run."""\n return {"passed": 0, "failed": 0, "skipped": 0, "errors": 0, "tests": []}\n\n\ndef _pll_run_collected_tests(ns, locs, run):\n """Run the `test_*` functions and `Test*` classes the program defined.\n\n `ns` is the program\'s own namespace, so the tests see exactly what it\n made; `run` is `(filename, code)` for the program, as `_pll_error_info`\n takes it. A Stop ends them: the tests that finished keep their results, the\n one running is marked as where it stopped, and the rest are not run.\n """\n result = _pll_tests_result()\n rows = result["tests"]\n current = None\n try:\n for name, fn in _pll_iter_tests(ns):\n current = name\n outcome, message, cap, error = _pll_call_test(fn, run)\n result[{"passed": "passed", "failed": "failed", "skipped": "skipped"}.get(outcome, "errors")] += 1\n rows.append({\n "name": name,\n "outcome": outcome,\n "line_number": locs.get(name),\n "message": message,\n "stdout": cap,\n # For the host\'s explainers only; never shown to a student.\n "error": error,\n })\n except KeyboardInterrupt:\n result["stopped"] = True\n result["stopped_in"] = current\n if current is not None:\n rows.append({\n "name": current,\n "outcome": "stopped",\n "line_number": locs.get(current),\n "message": None,\n "stdout": None,\n "error": None,\n })\n return result\n';
915
213
 
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
- }
214
+ // src/common/bootstrap/staticAnalysis.py
215
+ var staticAnalysis_default = '# Static analysis: the checks each level makes before a program runs.\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 - or, for a `def` or `class` written\n# twice, a duplicate definition. Suppressed for\n# names already flagged as shadowing in that scope\n# (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n# 4. Silent mistakes: code that runs without a word but cannot be what\n# was meant (`_PllSilenceVisitor`).\n#\n# intermediate:\n# The same, except that reassignment is only flagged at module scope.\n# Function/lambda/class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator patterns (e.g. `total = 0;\n# for x in xs: total += x` inside `def`).\n#\n# raw, 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\nimport ast as _ast\nimport sys as _sys\nimport builtins as _builtins_mod\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", "declared_elsewhere")\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 # Names this scope declared `global` or `nonlocal`. An assignment to\n # one of them rebinds the *outer* name, so it neither shadows nor\n # reassigns anything here - and the keyword itself is already\n # reported at the levels that disallow it.\n self.declared_elsewhere = set()\n\n\ndef _pll_redefines_on_purpose(node):\n """Whether a `def` is meant to share its name with one above it: a\n property\'s `@name.setter` (or `getter`, `deleter`), or an `@overload`."""\n for decorator in node.decorator_list:\n if (\n isinstance(decorator, _ast.Attribute)\n and decorator.attr in ("setter", "getter", "deleter")\n and isinstance(decorator.value, _ast.Name)\n and decorator.value.id == node.name\n ):\n return True\n if (isinstance(decorator, _ast.Name) and decorator.id == "overload") or (\n isinstance(decorator, _ast.Attribute) and decorator.attr == "overload"\n ):\n return True\n return False\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 self._classes = set()\n\n def build(self, tree):\n self._classes = {node.name for node in _ast.walk(tree) if isinstance(node, _ast.ClassDef)}\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 # No binding here, but an assignment further down binds the\n # outer name rather than a local one. Recorded so the shadowing\n # and reassignment checks do not report it twice over.\n scope.declared_elsewhere.update(node.names)\n return\n # A `case` pattern\'s captures, and `except ... as e`, bind a name -\n # one of the alternatives, of which only one runs. Not `case Boa:`\n # for a class `Boa`: that is the class with its brackets left off,\n # which the compiler\'s own error, or the match it then fits, says.\n if (\n isinstance(node, (_ast.MatchAs, _ast.MatchStar))\n and node.name is not None\n and not (isinstance(node, _ast.MatchAs) and node.pattern is None and node.name in self._classes)\n ):\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.MatchMapping) and node.rest is not None:\n self._add(scope, node.rest, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.ExceptHandler) and node.name is not None:\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n kind = "accessor" if _pll_redefines_on_purpose(node) else "functiondef"\n self._add(scope, node.name, node.lineno, node.col_offset, kind)\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\n#: The kinds that are said but do not stop the code running: each can be\n#: right as written. Every other kind is an error, which does.\n_PLL_WARNING_KINDS = frozenset(("method-not-called", "test-not-named"))\n\n\ndef _pll_finding(kind, error_type, line, column, name_token=None, **extras):\n """One static finding, in the shape the host reads (`RawStaticFinding`).\n\n `extras` are what this kind\'s explanation needs, and only that: the\n checks that walk scopes add `scope_kind`, because only they know it.\n """\n finding = {\n "id": kind,\n "error_type": error_type,\n "severity": "warning" if kind in _PLL_WARNING_KINDS else "error",\n "line_number": line,\n "column": column,\n "name_token": name_token,\n }\n finding.update(extras)\n return finding\n\n\n#: Annotations students write that are not types: `table` is the function\n#: that makes a table, `Table` the type. Written out rather than derived, so\n#: a name is only reported when its replacement is certainly right. The\n#: replacements are the host\'s (`TYPE_FOR` in silenceExplainer.ts), which\n#: words the advice.\n_PLL_NOT_A_TYPE = frozenset(\n (\n "table",\n "reactor",\n "row",\n "string",\n "integer",\n "boolean",\n "number",\n "Float",\n "Int",\n "Str",\n "Bool",\n "image",\n "Number",\n "String",\n "Boolean",\n "Integer",\n )\n)\n\n#: Methods that are nearly always meant to be called. `movies["rating"].mean`\n#: prints `<bound method Series.mean of ...>` and says nothing; `.mean()` is\n#: what was wanted. Only names that are a method everywhere a student meets\n#: them: `columns`, `values` and `shape` are properties in pandas, and\n#: `width` and `height` are properties on an image, so they are not here.\n_PLL_CALLED_METHODS = frozenset(\n (\n "mean",\n "sum",\n "count",\n "length",\n "rows",\n "head",\n "tail",\n "to_pandas",\n "to_svg",\n "upper",\n "lower",\n "strip",\n "split",\n "keys",\n "items",\n "sort_values",\n "value_counts",\n "describe",\n "median",\n "std",\n "var",\n "nunique",\n "interact",\n )\n)\n\n\n#: Functions whose whole job is to be given a function, where naming a\n#: method without calling it is exactly right.\n_PLL_TAKES_A_FUNCTION = frozenset(\n ("sorted", "map", "filter", "min", "max", "sort", "reduce", "any", "all")\n)\n\n#: Types a student might assign to a field name by mistake: `year = int`\n#: rather than `year: int`.\n_PLL_TYPE_NAMES = frozenset(("int", "float", "str", "bool", "list", "dict", "tuple"))\n\n#: Built-in types whose methods are passed as functions: `str.upper` is the\n#: function every string\'s `.upper()` calls, and can never be one forgotten.\n_PLL_METHOD_TYPES = frozenset(("str", "int", "float", "list", "dict", "set", "tuple", "bytes", "frozenset"))\n\n\ndef _pll_names_type(annotation):\n """Whether an annotation is `type` or `type[...]`."""\n if isinstance(annotation, _ast.Subscript):\n annotation = annotation.value\n return isinstance(annotation, _ast.Name) and annotation.id == "type"\n\n\nclass _PllSilenceVisitor(_ast.NodeVisitor):\n """Collect mistakes that run without a word being said.\n\n Each of these is valid Python that does nothing, or does something\n other than what was meant, and so produces no error at all:\n\n - a value computed and thrown away inside a function, which is how a\n test written without `assert` always passes;\n - `assert(x, 1)`, where the tuple is always true;\n - a function containing `assert` that is not named `test_...` and is\n never called, so it never runs;\n - an annotation naming a function rather than a type, which turns\n every check on that value off;\n - a method named but not called, which yields the method object;\n - a dataclass field with no type, or written `year = int`, which\n makes no field and goes wrong somewhere else entirely;\n - a class with fields and no `@dataclass`, whose constructor then\n "takes no arguments";\n - `a == Boa`, which is always False.\n\n Only inside a function for the first one: at the top level a bare\n expression is displayed, and the course relies on that.\n """\n\n __slots__ = (\n "found",\n "_code",\n "_defined",\n "_depth",\n "_called",\n "_asserting",\n "_classes",\n "_fields",\n "_type_valued",\n )\n\n def __init__(self, code, defined):\n self.found = []\n # The student\'s text, so advice can quote `return order_amt + 4`\n # rather than say "`return` it".\n self._code = code\n # Every name the file (or the session) binds at the top level: an\n # alias like `Number = int | float` is a type an annotation can name.\n self._defined = defined\n self._depth = 0\n # For each function being visited, its parameters annotated `type`,\n # whose values are classes and can be compared with one.\n self._type_valued = []\n # Classes defined in this file, so `== Boa` can be told from `== b`.\n self._classes = set()\n # Their field names. A field called `count` or `items` happens to\n # share its name with a method, and `s.count` is then exactly\n # right - so those are not "a method you forgot to call".\n self._fields = set()\n # Names used anywhere other than as the function\'s own definition,\n # so a helper that is never called can be told from one that is.\n self._called = set()\n # A finding for each function that contains an `assert` and is not\n # a test - reported only if nothing calls it, which is known at the end.\n self._asserting = []\n\n # ---- functions ----\n\n def scan(self, tree):\n """The findings for `tree`, with the classes and their fields collected first.\n\n `a == Boa` is only recognisable as a comparison against a class if\n `Boa` is already known, and a function written above the class it\n uses is perfectly ordinary. The field names are wanted for the same\n reason, by the checks that would otherwise mistake one for a method.\n """\n for node in _ast.walk(tree):\n if not isinstance(node, _ast.ClassDef):\n continue\n self._classes.add(node.name)\n for stmt in node.body:\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name):\n self._fields.add(stmt.target.id)\n self.visit(tree)\n self.found.extend(f for f in self._asserting if f["name_token"] not in self._called)\n return self.found\n\n def _function(self, node):\n self._depth += 1\n self._type_valued.append({\n arg.arg\n for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs\n if _pll_names_type(arg.annotation)\n })\n self.generic_visit(node)\n self._type_valued.pop()\n self._depth -= 1\n if self._contains_assert(node) and not node.name.startswith("test_"):\n self._asserting.append(\n _pll_finding("test-not-named", "NeverRun", node.lineno, node.col_offset, node.name)\n )\n self._not_a_type(node.returns)\n\n visit_FunctionDef = _function\n visit_AsyncFunctionDef = _function\n\n @staticmethod\n def _contains_assert(node):\n for child in _ast.walk(node):\n if isinstance(child, _ast.Assert):\n return True\n return False\n\n def visit_Name(self, node):\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.id)\n self.generic_visit(node)\n\n def visit_Attribute(self, node):\n # A method is called through its object: `a.check()`.\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.attr)\n self.generic_visit(node)\n\n # ---- classes and their fields ----\n\n def visit_ClassDef(self, node):\n self._classes.add(node.name)\n decorated = any(\n (isinstance(d, _ast.Name) and d.id == "dataclass")\n or (isinstance(d, _ast.Attribute) and d.attr == "dataclass")\n or (\n isinstance(d, _ast.Call)\n and (\n (isinstance(d.func, _ast.Name) and d.func.id == "dataclass")\n or (isinstance(d.func, _ast.Attribute) and d.func.attr == "dataclass")\n )\n )\n for d in node.decorator_list\n )\n annotated = [\n stmt\n for stmt in node.body\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name)\n ]\n for stmt in node.body:\n # `year` on a line of its own: meant as a field, but it is a\n # use of a name, so it fails as a NameError somewhere else.\n if isinstance(stmt, _ast.Expr) and isinstance(stmt.value, _ast.Name):\n self.found.append(\n _pll_finding(\n "field-no-type", "FieldNeedsType", stmt.lineno, stmt.col_offset, stmt.value.id\n )\n )\n # `year = int`: a class attribute holding a type, which makes\n # no field at all and goes wrong much later, in the argument\n # count of a constructor nobody wrote.\n if (\n isinstance(stmt, _ast.Assign)\n and len(stmt.targets) == 1\n and isinstance(stmt.targets[0], _ast.Name)\n and isinstance(stmt.value, _ast.Name)\n and stmt.value.id in _PLL_TYPE_NAMES\n ):\n self.found.append(\n _pll_finding(\n "field-assigned-type",\n "FieldNeedsType",\n stmt.lineno,\n stmt.col_offset,\n stmt.targets[0].id,\n # The type they wrote, so the fix quotes it back exactly.\n written_type=stmt.value.id,\n )\n )\n # Annotated fields and no `@dataclass`: `X(...)` then fails with\n # "takes no arguments", which says nothing about the decorator. Only\n # for a class with no base: a `NamedTuple` declares its fields this\n # way, and a subclass is made by its base\'s constructor.\n writes_init = any(\n isinstance(stmt, _PLL_SCOPE_FUNC) and stmt.name == "__init__"\n for stmt in node.body\n )\n has_base = any(not (isinstance(b, _ast.Name) and b.id == "object") for b in node.bases)\n if annotated and not decorated and not writes_init and not has_base:\n self.found.append(\n _pll_finding("class-needs-dataclass", "NotADataclass", node.lineno, node.col_offset, node.name)\n )\n self.generic_visit(node)\n\n def _class_valued(self, side):\n """Whether `side` is a class itself, which a class can equal:\n `type(a)`, `a.__class__`, another class, or a parameter annotated\n `type`."""\n if isinstance(side, _ast.Call) and isinstance(side.func, _ast.Name) and side.func.id == "type":\n return True\n if isinstance(side, _ast.Attribute) and side.attr == "__class__":\n return True\n return isinstance(side, _ast.Name) and (\n side.id in self._classes or any(side.id in names for names in self._type_valued)\n )\n\n def visit_Compare(self, node):\n # `if a == Boa:` is always False - a value is never equal to the\n # class it was made from. `type(a) == Boa`, though, is a real check.\n sides = [node.left] + list(node.comparators)\n # `x is 60.5`, `name is "Ada"`: `is` asks whether two are the same\n # object, which a number or a string written out need not be -\n # Python\'s SyntaxWarning, as a finding of the level\'s.\n for op, left, right in zip(node.ops, sides, sides[1:]):\n if isinstance(op, (_ast.Is, _ast.IsNot)):\n literal = next(\n (\n side\n for side in (left, right)\n if isinstance(side, _ast.Constant)\n and side.value is not None\n and not isinstance(side.value, bool)\n and side.value is not Ellipsis\n ),\n None,\n )\n if literal is not None:\n self.found.append(\n _pll_finding(\n "is-literal",\n "WrongComparison",\n node.lineno,\n node.col_offset,\n operator="is not" if isinstance(op, _ast.IsNot) else "is",\n literal=_ast.get_source_segment(self._code, literal),\n )\n )\n if any(isinstance(op, (_ast.Eq, _ast.NotEq)) for op in node.ops):\n for i, side in enumerate(sides):\n others = sides[:i] + sides[i + 1 :]\n if (\n isinstance(side, _ast.Name)\n and side.id in self._classes\n and not any(self._class_valued(other) for other in others)\n ):\n self.found.append(\n _pll_finding("compared-with-class", "AlwaysFalse", side.lineno, side.col_offset, side.id)\n )\n self.generic_visit(node)\n\n # ---- statements whose value goes nowhere ----\n\n def visit_Expr(self, node):\n value = node.value\n # A method named but not called is the more specific thing to say\n # about `t.mean` on a line of its own, so it wins.\n if not self._method_not_called(value) and self._depth > 0 and self._discarded(value):\n self.found.append(\n _pll_finding(\n "unused-comparison" if isinstance(value, _ast.Compare) else "unused-value",\n "UnusedValue",\n node.lineno,\n node.col_offset,\n expression=_ast.get_source_segment(self._code, value),\n )\n )\n self.generic_visit(node)\n\n @staticmethod\n def _discarded(value):\n """Whether this expression statement can only be a mistake.\n\n A call, an await or a yield is there for its effect. A string is a\n docstring. `...` is a placeholder. Everything else computes\n something and drops it.\n """\n if isinstance(\n value,\n (\n _ast.Call,\n _ast.Await,\n _ast.Yield,\n _ast.YieldFrom,\n _ast.NamedExpr,\n ),\n ):\n return False\n if isinstance(value, _ast.Constant):\n return False\n return True\n\n def _method_not_called(self, value):\n """`movies["rating"].mean` - the method itself, not its result."""\n if (\n isinstance(value, _ast.Attribute)\n and value.attr in _PLL_CALLED_METHODS\n and value.attr not in self._fields\n # `str.upper`: read off a type, so the function, on purpose.\n and not (\n isinstance(value.value, _ast.Name)\n and (value.value.id in _PLL_METHOD_TYPES or value.value.id in self._classes)\n )\n ):\n self.found.append(\n _pll_finding("method-not-called", "NotCalled", value.lineno, value.col_offset, value.attr)\n )\n return True\n return False\n\n def visit_Call(self, node):\n # A method named but not called, wherever its value is used:\n # `print(movies["rating"].mean)`.\n #\n # Not for a function that takes a function - `sorted(xs,\n # key=str.lower)` passes `str.lower` deliberately, and that is the\n # one shape where naming a method without calling it is right.\n callee = node.func\n name = callee.id if isinstance(callee, _ast.Name) else None\n if name in _PLL_TAKES_A_FUNCTION:\n self.generic_visit(node)\n return\n for keyword in node.keywords:\n if keyword.arg not in ("key", "default_factory"):\n self._method_not_called(keyword.value)\n for argument in node.args:\n self._method_not_called(argument)\n self.generic_visit(node)\n\n # ---- asserts ----\n\n def visit_Assert(self, node):\n if isinstance(node.test, _ast.Tuple) and node.test.elts:\n self.found.append(_pll_finding("assert-tuple", "AlwaysTrue", node.lineno, node.col_offset))\n self.generic_visit(node)\n\n # ---- annotations ----\n\n def _not_a_type(self, annotation):\n if (\n isinstance(annotation, _ast.Name)\n and annotation.id in _PLL_NOT_A_TYPE\n # A class or alias of their own called `Number` is a type, and\n # naming it in an annotation is right.\n and annotation.id not in self._classes\n and annotation.id not in self._defined\n ):\n self.found.append(\n _pll_finding(\n "annotation-not-a-type", "NotAType", annotation.lineno, annotation.col_offset, annotation.id\n )\n )\n\n def _annotation(self, node):\n self._not_a_type(getattr(node, "annotation", None))\n self.generic_visit(node)\n\n visit_AnnAssign = _annotation\n visit_arg = _annotation\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 module = _pll_sessions.get(session_key)\n if module is None:\n return []\n g = module.__dict__\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 made by `_pll_finding`: `id`, `error_type`, a\n position and `name_token`, and whatever that kind\'s explanation needs.\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 _PLL_TEACHING_LEVELS:\n return []\n try:\n with _pll_without_syntax_warnings():\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. Mirrored by `levelRefusesReassignment` in level.ts,\n # which the explanations use to offer only fixes the level accepts.\n def reassignment_active(scope_kind):\n if level == _PLL_LEVEL_BEGINNER:\n return True\n if level == _PLL_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, binding 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, outer_first[2])\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n if name in scope.declared_elsewhere:\n # `global x` has its own finding; one for the same name here\n # would read as a second, separate mistake.\n continue\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 nothing\n # is shadowed. The class\'s own name is bound in the\n # enclosing scope and checked there, so `class list:` is\n # 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(_pll_finding(\n "shadowing", "Shadowing", report_loc[0], report_loc[1], name,\n scope_kind=scope.kind,\n binding=report_loc[2],\n outer_line_number=outer[0],\n outer_column=outer[1],\n outer_scope_kind=outer[2],\n outer_binding=outer[3],\n ))\n elif name in builtins_set and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append(_pll_finding(\n "shadowing-builtin", "Shadowing", defining_loc[0], defining_loc[1], 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 findings.append(_pll_finding(\n "shadowing-library", "Shadowing", defining_loc[0], defining_loc[1], name,\n scope_kind=scope.kind,\n library=library_names[name],\n binding=defining_loc[2],\n module=defining_loc[3],\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 or name in scope.declared_elsewhere:\n continue\n # A property\'s setter is the property, not a second one.\n locs = [loc for loc in locs if loc[2] != "accessor"]\n # The same capture in two `case`s (or two `except`s) is bound\n # by whichever one runs, never twice.\n if all(loc[2] == "capture" for loc in locs):\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n # A `def` written twice is not an accumulator: the\n # advice for a reassigned variable (running totals, use\n # `sum`) is about something else entirely, and the fix\n # is to rename one of them.\n definitions = {"functiondef": "function", "classdef": "class"}\n both = definitions.get(first_loc[2]) if first_loc[2] == second_loc[2] else None\n if both is not None:\n findings.append(_pll_finding(\n "duplicate-definition", "DuplicateDefinition", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n definition_kind=both,\n ))\n continue\n findings.append(_pll_finding(\n "reassignment", "Reassignment", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n ))\n\n # ---- `global` / `nonlocal` ----\n # Both are disallowed at beginner and intermediate. One finding per\n # statement, not per name, so `global x, y` is one diagnostic on its line.\n for node in _ast.walk(tree):\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n findings.append(_pll_finding(\n "disallowed-keyword", "DisallowedKeyword", node.lineno, node.col_offset, node.names[0],\n keyword="global" if isinstance(node, _ast.Global) else "nonlocal",\n names=list(node.names),\n ))\n\n # ---- mistakes that would otherwise run without a word ----\n findings.extend(_PllSilenceVisitor(code, set(builder.scopes[0].bindings)).scan(tree))\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
934
216
 
217
+ // src/common/bootstrap/imports.py
218
+ var imports_default = '# The student\'s own files, imported as they run: each held to its own\n# `#level`, whichever file imports it. A grader importing a student\'s file\n# gets that file\'s checks, and a `#level beginner` helper is still beginner\n# when `main.py` imports it.\n#\n# On import, a file of theirs is checked as a run would check it - a broken\n# `#level` line, then the static checks of its level - and refused with\n# `ChecksFailed` if it does not pass. Then it is compiled with its level\'s\n# annotation checks, and starts with the names a run starts with (the\n# libraries\' among them).\n\nimport importlib.machinery as _pll_machinery\nimport importlib.util as _pll_import_util\nimport json as _pll_json\nimport sys as _pll_import_sys\n\n#: Each mounted `.py` file\'s level, as the host read it from its `#level`\n#: line (`level.ts`): path under the work directory (`helpers/shapes.py`)\n#: -> (level, header problem `{line, message}` or None). A file not in it -\n#: one a program wrote and then imported - has no level the host read, and\n#: imports as `raw`.\n_pll_file_levels = {}\n\n#: A module\'s own identity, which a copy of `_pll_initial_globals` must not\n#: replace.\n_PLL_MODULE_OWN = ("__name__", "__file__", "__loader__", "__spec__", "__package__", "__cached__", "__doc__")\n\n\ndef _pll_note_file_levels(levels_json):\n """Record the levels of the files just mounted."""\n _pll_file_levels.clear()\n for name, (level, problem) in _pll_json.loads(levels_json).items():\n _pll_file_levels[name] = (level, problem)\n\n\nclass ChecksFailed(ImportError):\n """One of the student\'s files did not pass its own level\'s checks, so\n it was not imported."""\n\n\ndef _pll_student_path(path):\n """`path` under the work directory, as the host names it, or None for a\n file that is not the student\'s."""\n prefix = _PLL_WORK_DIR + "/"\n if isinstance(path, str) and path.startswith(prefix) and path.endswith(".py"):\n return path[len(prefix) :]\n return None\n\n\ndef _pll_level_of_file(path):\n return _pll_file_levels.get(_pll_student_path(path), (_PLL_LEVEL_RAW, None))\n\n\nclass _PllStudentLoader(_pll_machinery.SourceFileLoader):\n """Loads one of the student\'s files at its own level.\n\n Always from its source: a cached `.pyc` would be the file compiled for\n whatever level it had then, with or without its checks.\n """\n\n def get_code(self, fullname):\n path = self.get_filename(fullname)\n source = _pll_import_util.decode_source(self.get_data(path))\n level, problem = _pll_level_of_file(path)\n name = _pll_student_path(path)\n blocking = []\n if problem is None:\n blocking = [f for f in _pll_static_analyze(source, level, path) if f["severity"] == "error"]\n if problem is not None or blocking:\n error = ChecksFailed(\n "%s did not pass the checks of #level %s" % (name, level), name=fullname, path=path\n )\n _pll_add_facts(error, checks={\n "file": name,\n "level": level,\n "findings": blocking,\n "header_problem": problem,\n })\n raise error\n tree = _pll_parse_and_instrument(source, path, level)\n with _pll_recording_compile_warnings():\n return compile(tree, path, "exec", dont_inherit=True)\n\n def exec_module(self, module):\n own = module.__dict__\n for key, value in _pll_initial_globals.items():\n if key not in _PLL_MODULE_OWN:\n own.setdefault(key, value)\n own["__pll_level__"] = _pll_level_of_file(self.get_filename(module.__name__))[0]\n super().exec_module(module)\n\n\nclass _PllStudentFinder:\n """Finds the student\'s files for `_PllStudentLoader`, by the search\n Python itself makes: a file under the program\'s folder is theirs only\n when nothing earlier on `sys.path` has its name\n (`_pll_protect_import_path`) - `helpers/shapes.py` as `helpers.shapes`\n too.\n """\n\n @classmethod\n def find_spec(cls, fullname, path=None, target=None):\n spec = _pll_machinery.PathFinder.find_spec(fullname, path, target)\n origin = getattr(spec, "origin", None)\n if _pll_student_path(origin) is not None:\n spec.loader = _PllStudentLoader(fullname, origin)\n return spec\n\n @classmethod\n def invalidate_caches(cls):\n pass\n\n\ndef _pll_install_student_finder():\n """Put the finder just before Python\'s own path search, so built-in and\n frozen modules are found as they always are."""\n meta_path = _pll_import_sys.meta_path\n if _PllStudentFinder in meta_path:\n return\n at = next((i for i, f in enumerate(meta_path) if f is _pll_machinery.PathFinder), len(meta_path))\n meta_path.insert(at, _PllStudentFinder)\n\n\n_pll_install_student_finder()\n';
935
219
 
936
- # -----------------------------------------------------------------------------
937
- # Names exported into user globals by the bootstrap.
938
- # -----------------------------------------------------------------------------
220
+ // src/common/imageLib.py
221
+ 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.\n#\n# An image is a tree: shapes at the leaves, combinators above them. Each\n# node knows its size when it is made, and where its children sit inside it\n# (`_parts`). Drawing walks the tree once, with a stack rather than\n# recursion, into a flat list of shapes in the picture\'s own coordinates\n# (`_pll_flatten`). That list is what is rendered as SVG, what `rotate`\n# measures its box from, and what `==` compares: two images are equal when\n# they draw the same shapes, in the same places, in the same colours.\n#\n# This module is loaded into Pyodide after the bootstrap (`bootstrap/`).\n# The install step then copies the public names into each session\'s\n# globals, so a beginner can write `circle(50, "solid", "red")` with no\n# import (matching the HtDP/Pyret experience).\n\nimport base64 as _pll_img_b64\nimport heapq as _pll_img_heapq\nimport math as _math\nimport numbers as _pll_img_numbers\nimport os as _pll_img_os\nimport re as _pll_img_re\nimport sys as _pll_img_sys\nimport unicodedata as _pll_img_unicodedata\n\n\n# -----------------------------------------------------------------------------\n# Colours\n# -----------------------------------------------------------------------------\n\n#: Every named colour in CSS Color 4 - the names SVG accepts - with its\n#: value, so `"red"` and `(255, 0, 0)` are the same colour to `==`. There are\n#: 148 names; the count is asserted by the smoke tests, so dropping one fails\n#: loudly rather than rejecting a colour that works.\n_PLL_CSS_COLORS = {}\n_pll_color_words = """\n aliceblue f0f8ff antiquewhite faebd7 aqua 00ffff aquamarine 7fffd4\n azure f0ffff beige f5f5dc bisque ffe4c4 black 000000\n blanchedalmond ffebcd blue 0000ff blueviolet 8a2be2 brown a52a2a\n burlywood deb887 cadetblue 5f9ea0 chartreuse 7fff00 chocolate d2691e\n coral ff7f50 cornflowerblue 6495ed cornsilk fff8dc crimson dc143c\n cyan 00ffff darkblue 00008b darkcyan 008b8b darkgoldenrod b8860b\n darkgray a9a9a9 darkgreen 006400 darkgrey a9a9a9 darkkhaki bdb76b\n darkmagenta 8b008b darkolivegreen 556b2f darkorange ff8c00\n darkorchid 9932cc darkred 8b0000 darksalmon e9967a\n darkseagreen 8fbc8f darkslateblue 483d8b darkslategray 2f4f4f\n darkslategrey 2f4f4f darkturquoise 00ced1 darkviolet 9400d3\n deeppink ff1493 deepskyblue 00bfff dimgray 696969 dimgrey 696969\n dodgerblue 1e90ff firebrick b22222 floralwhite fffaf0\n forestgreen 228b22 fuchsia ff00ff gainsboro dcdcdc ghostwhite f8f8ff\n gold ffd700 goldenrod daa520 gray 808080 green 008000\n greenyellow adff2f grey 808080 honeydew f0fff0 hotpink ff69b4\n indianred cd5c5c indigo 4b0082 ivory fffff0 khaki f0e68c\n lavender e6e6fa lavenderblush fff0f5 lawngreen 7cfc00\n lemonchiffon fffacd lightblue add8e6 lightcoral f08080\n lightcyan e0ffff lightgoldenrodyellow fafad2 lightgray d3d3d3\n lightgreen 90ee90 lightgrey d3d3d3 lightpink ffb6c1\n lightsalmon ffa07a lightseagreen 20b2aa lightskyblue 87cefa\n lightslategray 778899 lightslategrey 778899 lightsteelblue b0c4de\n lightyellow ffffe0 lime 00ff00 limegreen 32cd32 linen faf0e6\n magenta ff00ff maroon 800000 mediumaquamarine 66cdaa\n mediumblue 0000cd mediumorchid ba55d3 mediumpurple 9370db\n mediumseagreen 3cb371 mediumslateblue 7b68ee mediumspringgreen 00fa9a\n mediumturquoise 48d1cc mediumvioletred c71585 midnightblue 191970\n mintcream f5fffa mistyrose ffe4e1 moccasin ffe4b5 navajowhite ffdead\n navy 000080 oldlace fdf5e6 olive 808000 olivedrab 6b8e23\n orange ffa500 orangered ff4500 orchid da70d6 palegoldenrod eee8aa\n palegreen 98fb98 paleturquoise afeeee palevioletred db7093\n papayawhip ffefd5 peachpuff ffdab9 peru cd853f pink ffc0cb\n plum dda0dd powderblue b0e0e6 purple 800080 rebeccapurple 663399\n red ff0000 rosybrown bc8f8f royalblue 4169e1 saddlebrown 8b4513\n salmon fa8072 sandybrown f4a460 seagreen 2e8b57 seashell fff5ee\n sienna a0522d silver c0c0c0 skyblue 87ceeb slateblue 6a5acd\n slategray 708090 slategrey 708090 snow fffafa springgreen 00ff7f\n steelblue 4682b4 tan d2b48c teal 008080 thistle d8bfd8\n tomato ff6347 turquoise 40e0d0 violet ee82ee wheat f5deb3\n white ffffff whitesmoke f5f5f5 yellow ffff00 yellowgreen 9acd32\n""".split()\nfor _pll_i in range(0, len(_pll_color_words), 2):\n _pll_hex = _pll_color_words[_pll_i + 1]\n _PLL_CSS_COLORS[_pll_color_words[_pll_i]] = (\n int(_pll_hex[0:2], 16),\n int(_pll_hex[2:4], 16),\n int(_pll_hex[4:6], 16),\n )\ndel _pll_color_words, _pll_i, _pll_hex\n_PLL_CSS_COLOR_NAMES = frozenset(_PLL_CSS_COLORS)\n\n#: A colour *name*, once spaces, hyphens and underscores are taken out:\n#: shaped like a word, and then checked against the list above.\n_PLL_COLOR_NAME_RE = _pll_img_re.compile(r"^[a-z]+$")\n_PLL_COLOR_HEX_RE = _pll_img_re.compile(r"^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$")\n_PLL_COLOR_FN_RE = _pll_img_re.compile(r"^(rgba?|hsla?)\\s*\\((.*)\\)$", _pll_img_re.S)\n#: One argument of `rgb(...)` or `hsl(...)`: a number, maybe with a unit.\n_PLL_COLOR_ARG_RE = _pll_img_re.compile(\n r"^([+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:e[+-]?\\d+)?)(%|deg)?$"\n)\n\n_PLL_MODES = ("solid", "outline")\n\n\nclass _Paint:\n """A colour as it is drawn: its CSS, its opacity, and what `==` compares.\n\n Made from what the student wrote when the shape is made, so a list of\n numbers changed afterwards changes nothing already drawn. The CSS is\n always written by PLL - a name from the list above, or `#rrggbb` - so\n nothing of the student\'s string reaches the SVG.\n """\n\n __slots__ = ("css", "alpha", "key")\n\n def __init__(self, css, rgb, alpha):\n self.css = css\n self.alpha = alpha\n # `currentColor` is whatever colour the text around it is, which is\n # the same as no other colour.\n self.key = (tuple(rgb) if rgb is not None else (-1, -1, -1)) + (round(alpha, 4),)\n\n\ndef _pll_hex_css(rgb):\n return "#%02x%02x%02x" % tuple(rgb)\n\n\n_PLL_TRANSPARENT = _Paint("none", (0, 0, 0), 0.0)\n_PLL_BLACK = _Paint("black", (0, 0, 0), 1.0)\n_PLL_WHITE = _Paint("white", (255, 255, 255), 1.0)\n\n\n#: Filled in at the end of this module, from the functions themselves, so\n#: a message can say "the 3rd argument (mode)" and show the whole contract\n#: without a second list of parameter names to keep in step.\n_PLL_PARAMS = {}\n\n\ndef _pll_where(who, param):\n """" (the 3rd argument)", or "" when the position is not known."""\n names = _PLL_PARAMS.get(who)\n if not names or param not in names:\n return ""\n return " (the %s argument)" % _pll_ordinal(names.index(param))\n\n\ndef _pll_contract(who):\n """" The arguments are: rectangle(width, height, mode, color)." """\n names = _PLL_PARAMS.get(who)\n # Only worth showing when there are enough arguments to get lost\n # among; `frame(image)` explains itself.\n if not names or len(names) < 3:\n return ""\n return " The arguments are: %s(%s)." % (who, ", ".join(names))\n\n\ndef _pll_check_image(value, who, index):\n """One argument of a combining function.\n\n Checked when the function is called, so `beside(a, "austria")` fails\n there, naming `beside` and the argument, rather than later inside the\n rendering code - after the broken picture has been displayed - with\n "\'str\' object has no attribute \'width\'".\n """\n if isinstance(value, Image):\n return value\n raise TypeError(\n "%s\'s %s argument is %s, not an image."\n % (who, _pll_ordinal(index), _pll_describe(value))\n )\n\n\ndef _pll_check_images(images, who, offset=0, least=0):\n """Every image argument of a combining function.\n\n `offset` is how many arguments come before them, so `beside_align`\n counts its images from the second position.\n """\n if len(images) == 1 and isinstance(images[0], (list, tuple)):\n raise TypeError(\n "%s takes the images themselves, not a list of them: "\n "write %s(first, second) rather than %s([first, second])."\n % (who, who, who)\n )\n if len(images) < least:\n raise TypeError(\n "%s needs at least one image to put together, like %s(first, second)."\n % (who, who)\n )\n for index, value in enumerate(images):\n _pll_check_image(value, who, index + offset)\n return images\n\n\ndef _pll_real(value):\n """Whether `value` is a number - numpy\'s too - and not a bool."""\n return isinstance(value, _pll_img_numbers.Real) and not isinstance(value, bool)\n\n\ndef _pll_check_number(value, who, param):\n """A number, which may be negative - an offset or an angle - as a float.\n\n NaN and the infinities are numbers to Python and nothing to draw with;\n let through, they failed much later, in Python\'s words.\n """\n if not _pll_real(value):\n raise TypeError(\n "%s\'s `%s`%s must be a number, but it is %s.%s"\n % (who, param, _pll_where(who, param), _pll_describe(value), _pll_contract(who))\n )\n try:\n number = float(value)\n except OverflowError:\n raise ValueError(\n "%s\'s `%s`%s is a number too big to draw with.%s"\n % (who, param, _pll_where(who, param), _pll_contract(who))\n ) from None\n if number != number:\n raise ValueError(\n "%s\'s `%s`%s is NaN (\\"not a number\\"), which cannot be drawn with.%s"\n % (who, param, _pll_where(who, param), _pll_contract(who))\n )\n if number in (_math.inf, -_math.inf):\n raise ValueError(\n "%s\'s `%s`%s cannot be infinite, but it is %s.%s"\n % (who, param, _pll_where(who, param), _pll_number(number), _pll_contract(who))\n )\n return number\n\n\ndef _pll_check_size(value, who, param, least=0):\n """A size in pixels: a number, and never negative.\n\n A string size (`rectangle("20", 20, ...)`) and a negative one were both\n accepted, and drew the wrong thing or nothing at all without a word.\n """\n number = _pll_check_number(value, who, param)\n if number < least:\n raise ValueError(\n "%s\'s `%s`%s cannot be %s, but it is %s.%s"\n % (\n who,\n param,\n _pll_where(who, param),\n "negative" if least == 0 else "less than %s" % _pll_number(least),\n _pll_number(value),\n _pll_contract(who),\n )\n )\n return number\n\n\ndef _pll_check_positive(value, who, param):\n """A number that has to be more than zero, like a scale factor."""\n number = _pll_check_number(value, who, param)\n if number <= 0:\n raise ValueError(\n "%s\'s `%s`%s has to be more than 0, but it is %s.%s"\n % (who, param, _pll_where(who, param), _pll_number(value), _pll_contract(who))\n )\n return number\n\n\ndef _pll_check_count(value, who, param, least):\n """How many of something: a whole number, at least `least`."""\n if isinstance(value, _pll_img_numbers.Integral) and not isinstance(value, bool):\n count = int(value)\n elif isinstance(value, float) and value.is_integer():\n raise TypeError(\n "%s\'s `%s`%s has to be a whole number, but it is %s: write %d.%s"\n % (who, param, _pll_where(who, param), value, int(value), _pll_contract(who))\n )\n else:\n _pll_check_number(value, who, param)\n raise TypeError(\n "%s\'s `%s`%s has to be a whole number, but it is %s.%s"\n % (who, param, _pll_where(who, param), _pll_describe(value), _pll_contract(who))\n )\n if count < least:\n raise ValueError(\n "%s\'s `%s`%s cannot be less than %d, but it is %d.%s"\n % (who, param, _pll_where(who, param), least, count, _pll_contract(who))\n )\n return count\n\n\ndef _pll_check_order(first, second, who, param):\n """The two arguments the other way round.\n\n `rotate(image, 45)` fails inside the arithmetic with "float() argument\n must be a string or a real number, not \'_Frame\'", which names an\n internal class and not the mistake.\n """\n if isinstance(first, Image) and _pll_real(second):\n raise TypeError(\n "%s takes the `%s` first, then the image: write %s(%s, image)."\n % (who, param, who, _pll_number(second))\n )\n\n\ndef _pll_check_color(color, who):\n """The colour `color` names, as a `_Paint`; anything else is an error.\n\n SVG ignores a paint value it cannot parse, so an unchecked\n `rectangle(30, 40, "solid", 50)` would draw an invisible rectangle and\n say 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 checked against the CSS colours, which are the names SVG\n accepts, so a misspelling is caught rather than drawn as nothing. The\n list is the whole of CSS Color 4 and its length is asserted by the\n tests: rejecting a colour that works would be worse than the typo.\n """\n if isinstance(color, str):\n return _pll_color_string(color, who)\n if isinstance(color, (tuple, list)) and len(color) in (3, 4):\n return _pll_color_numbers(list(color), who)\n raise ValueError(_pll_not_a_colour(who, color))\n\n\ndef _pll_color_string(color, who):\n text = color.strip().lower()\n if text in _PLL_CSS_COLORS:\n return _Paint(text, _PLL_CSS_COLORS[text], 1.0)\n if text in ("transparent", "none"):\n return _PLL_TRANSPARENT\n if text == "currentcolor":\n return _Paint("currentColor", None, 1.0)\n found = _PLL_COLOR_HEX_RE.match(text)\n if found:\n digits = found.group(1)\n if len(digits) <= 4:\n digits = "".join(d * 2 for d in digits)\n rgb = tuple(int(digits[i:i + 2], 16) for i in (0, 2, 4))\n alpha = int(digits[6:8], 16) / 255.0 if len(digits) == 8 else 1.0\n return _Paint(_pll_hex_css(rgb), rgb, alpha)\n found = _PLL_COLOR_FN_RE.match(text)\n if found:\n return _pll_color_function(found.group(1), found.group(2), color, who)\n # "light blue", "light-blue": CSS writes every name as one word.\n squeezed = _pll_img_re.sub(r"[\\s_-]+", "", text)\n if _PLL_COLOR_NAME_RE.match(squeezed):\n raise ValueError(_pll_unknown_colour(who, color, squeezed))\n raise ValueError(_pll_not_a_colour(who, color))\n\n\ndef _pll_color_function(kind, inner, color, who):\n """`rgb(...)`, `rgba(...)`, `hsl(...)` or `hsla(...)`, read strictly.\n\n Only numbers, with `%` or `deg` where CSS allows them, separated as CSS\n separates them: by commas, or by spaces with the opacity after a `/`.\n """\n example = \'"rgb(255, 0, 0)"\' if kind.startswith("rgb") else \'"hsl(0, 100%, 50%)"\'\n bad = ValueError(\n "%s\'s `color`%s is %s, which is not a color. Write %s(...) with three "\n "numbers, like %s."\n % (who, _pll_where(who, "color"), _pll_describe(color), kind.rstrip("a"), example)\n )\n inner = inner.strip()\n if "," in inner:\n parts = [part.strip() for part in inner.split(",")]\n alpha = parts.pop() if len(parts) == 4 else None\n else:\n main, slash, alpha = inner.partition("/")\n parts = main.split()\n alpha = alpha.strip() if slash else None\n if len(parts) != 3:\n raise bad\n args = [_PLL_COLOR_ARG_RE.match(part) for part in parts + ([alpha] if alpha is not None else [])]\n if None in args:\n raise bad\n values = [(float(arg.group(1)), arg.group(2)) for arg in args]\n\n def out_of_range(i, low, high, unit=""):\n return ValueError(\n "%s\'s `color`%s: the %s part runs from %s%s to %s%s, but it is %s%s."\n % (\n who,\n _pll_where(who, "color"),\n ("red green blue" if kind.startswith("rgb") else "hue saturation lightness").split()[i]\n if i < 3\n else "opacity",\n low, unit, high, unit,\n _pll_number(values[i][0]),\n values[i][1] or "",\n )\n )\n\n if kind.startswith("rgb"):\n rgb = []\n for i, (number, unit) in enumerate(values[:3]):\n if unit == "deg":\n raise bad\n if unit == "%":\n if not 0 <= number <= 100:\n raise out_of_range(i, 0, 100, "%")\n number = number * 255 / 100\n elif not 0 <= number <= 255:\n raise out_of_range(i, 0, 255)\n rgb.append(int(round(number)))\n else:\n (hue, hue_unit), (sat, sat_unit), (light, light_unit) = values[:3]\n if hue_unit == "%" or sat_unit == "deg" or light_unit == "deg":\n raise bad\n for i, value in ((1, sat), (2, light)):\n if not 0 <= value <= 100:\n raise out_of_range(i, 0, 100, "%")\n rgb = _pll_hsl_to_rgb(hue % 360, sat / 100, light / 100)\n opacity = 1.0\n if alpha is not None:\n number, unit = values[3]\n if unit == "deg":\n raise bad\n opacity = number / 100 if unit == "%" else number\n if not 0 <= opacity <= 1:\n raise out_of_range(3, 0, 100, "%") if unit == "%" else out_of_range(3, 0, 1)\n return _Paint(_pll_hex_css(rgb), rgb, opacity)\n\n\ndef _pll_hsl_to_rgb(hue, sat, light):\n """CSS\'s conversion, to whole numbers from 0 to 255."""\n\n def channel(n):\n k = (n + hue / 30) % 12\n a = sat * min(light, 1 - light)\n return light - a * max(-1, min(k - 3, 9 - k, 1))\n\n return tuple(int(round(channel(n) * 255)) for n in (0, 8, 4))\n\n\ndef _pll_color_numbers(parts, who):\n """`(red, green, blue)` or `(red, green, blue, opacity)`.\n\n The opacity is written either way, and which is told by its type: a\n whole number runs from 0 to 255 like the others, a fraction from 0.0 to\n 1.0. So `(255, 0, 0, 128)` and `(255, 0, 0, 0.5)` are both half see-\n through, and `(255, 0, 0, 1)` is almost invisible where `1.0` is solid.\n """\n names = ("red", "green", "blue", "opacity")\n for part in parts:\n if not _pll_real(part):\n raise ValueError(\n "%s\'s `color`%s has a part that is not a number: %s."\n % (who, _pll_where(who, "color"), _pll_describe(part))\n )\n rgb = []\n for i, part in enumerate(parts[:3]):\n if not 0 <= part <= 255:\n raise ValueError(\n "%s\'s `color`%s: the %s part runs from 0 to 255, but it is %s."\n % (who, _pll_where(who, "color"), names[i], _pll_number(part))\n )\n rgb.append(int(round(float(part))))\n alpha = 1.0\n if len(parts) == 4:\n part = parts[3]\n if isinstance(part, _pll_img_numbers.Integral):\n if not 0 <= part <= 255:\n raise ValueError(\n "%s\'s `color`%s: the opacity part runs from 0 to 255 as a whole "\n "number, or from 0.0 to 1.0 as a fraction, but it is %s."\n % (who, _pll_where(who, "color"), _pll_number(part))\n )\n alpha = int(part) / 255.0\n else:\n if not 0 <= part <= 1:\n raise ValueError(\n "%s\'s `color`%s: the opacity part is a fraction from 0.0 to 1.0 "\n "(or a whole number from 0 to 255), but it is %s."\n % (who, _pll_where(who, "color"), _pll_number(part))\n )\n alpha = float(part)\n return _Paint(_pll_hex_css(rgb), rgb, alpha)\n\n\ndef _pll_not_a_colour(who, color):\n """The one wording for "that is not a colour", wherever it is noticed."""\n return (\n "%s\'s `color`%s is %s, which is not a color. Use a name like \\"red\\", "\n "a hex code like \\"#ff0000\\", or (red, green, blue) numbers from 0 to 255."\n % (who, _pll_where(who, "color"), _pll_describe(color))\n )\n\n\ndef _pll_unknown_colour(who, color, squeezed):\n """A word that is shaped like a colour name and is not one."""\n if squeezed in _PLL_CSS_COLORS:\n suggestion = squeezed\n else:\n suggestion = _pll_closest_name(squeezed, _PLL_CSS_COLOR_NAMES)\n return "%s\'s `color`%s is %s, which is not a color name PLL knows.%s" % (\n who,\n _pll_where(who, "color"),\n _pll_describe(color),\n \' Did you mean "%s"?\' % suggestion\n if suggestion is not None\n else \' Use a name like "red", a hex code like "#ff0000", or \'\n "(red, green, blue) numbers from 0 to 255.",\n )\n\n\ndef _pll_check_mode(mode, who):\n """`solid` or `outline`, and nothing else.\n\n Anything else is an error, as a bad colour is: falling through to solid\n would quietly fill the shape for a misspelled "outilne".\n """\n if mode not in _PLL_MODES:\n raise ValueError(\n "%s\'s `mode`%s should be \\"solid\\" or \\"outline\\", but it is %s.%s"\n % (who, _pll_where(who, "mode"), _pll_describe(mode), _pll_contract(who))\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\n# -----------------------------------------------------------------------------\n# Geometry: transforms, flattening, clipping\n# -----------------------------------------------------------------------------\n#\n# A transform is SVG\'s `matrix(a, b, c, d, e, f)`: a point (x, y) goes to\n# (a*x + c*y + e, b*x + d*y + f). PLL only ever makes similarities - moves,\n# turns, flips and even scaling - but nothing below relies on it.\n\n_PLL_IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)\n\n\ndef _pll_translate(dx, dy):\n return (1.0, 0.0, 0.0, 1.0, float(dx), float(dy))\n\n\ndef _pll_compose(outer, inner):\n """The transform that applies `inner`, then `outer`."""\n a, b, c, d, e, f = outer\n a2, b2, c2, d2, e2, f2 = inner\n return (\n a * a2 + c * b2,\n b * a2 + d * b2,\n a * c2 + c * d2,\n b * c2 + d * d2,\n a * e2 + c * f2 + e,\n b * e2 + d * f2 + f,\n )\n\n\ndef _pll_apply(m, x, y):\n return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])\n\n\ndef _pll_corners(m, w, h):\n """The corners of a `w` x `h` box drawn with `m`, in drawing order."""\n return (_pll_apply(m, 0, 0), _pll_apply(m, w, 0), _pll_apply(m, w, h), _pll_apply(m, 0, h))\n\n\ndef _pll_bounds(points):\n xs = [p[0] for p in points]\n ys = [p[1] for p in points]\n return (min(xs), min(ys), max(xs), max(ys))\n\n\ndef _pll_area(points):\n """Twice the signed area; positive when the points turn one way."""\n total = 0.0\n n = len(points)\n for i in range(n):\n x1, y1 = points[i]\n x2, y2 = points[(i + 1) % n]\n total += x1 * y2 - x2 * y1\n return total\n\n\ndef _pll_convex_and(subject, clipper):\n """The part of convex polygon `subject` inside convex polygon `clipper`.\n\n Sutherland-Hodgman, edge by edge. Empty when they do not meet.\n """\n turn = 1.0 if _pll_area(clipper) >= 0 else -1.0\n out = list(subject)\n n = len(clipper)\n for i in range(n):\n if not out:\n break\n ax, ay = clipper[i]\n bx, by = clipper[(i + 1) % n]\n\n def side(p):\n return turn * ((bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax))\n\n given = out\n out = []\n prev = given[-1]\n prev_side = side(prev)\n for cur in given:\n cur_side = side(cur)\n if cur_side >= -1e-9:\n if prev_side < -1e-9:\n t = prev_side / (prev_side - cur_side)\n out.append((prev[0] + t * (cur[0] - prev[0]), prev[1] + t * (cur[1] - prev[1])))\n out.append(cur)\n elif prev_side >= -1e-9:\n t = prev_side / (prev_side - cur_side)\n out.append((prev[0] + t * (cur[0] - prev[0]), prev[1] + t * (cur[1] - prev[1])))\n prev, prev_side = cur, cur_side\n kept = []\n for p in out:\n if not kept or abs(p[0] - kept[-1][0]) > 1e-9 or abs(p[1] - kept[-1][1]) > 1e-9:\n kept.append(p)\n if len(kept) > 1 and abs(kept[0][0] - kept[-1][0]) <= 1e-9 and abs(kept[0][1] - kept[-1][1]) <= 1e-9:\n kept.pop()\n return tuple(kept) if len(kept) >= 3 else ()\n\n\ndef _pll_inside(polygon, points):\n """Whether every one of `points` is inside convex `polygon`."""\n turn = 1.0 if _pll_area(polygon) >= 0 else -1.0\n n = len(polygon)\n for i in range(n):\n ax, ay = polygon[i]\n bx, by = polygon[(i + 1) % n]\n for px, py in points:\n if turn * ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) < -1e-6:\n return False\n return True\n\n\n# An item is one shape, ready to draw: its kind, the convex polygon it is\n# clipped to (or None), and the kind\'s own geometry, all in the coordinates\n# of the picture being drawn:\n#\n# ("poly", clip, points, mode, paint)\n# ("ellipse", clip, cx, cy, rx, ry, degrees, mode, paint) rx along degrees\n# ("line", clip, x1, y1, x2, y2, paint)\n# ("text", clip, matrix, text, size, width, height, paint)\n# ("bitmap", clip, matrix, width, height, loaded_image)\n# ("svg", clip, matrix, width, height, markup)\n# ("box", clip, points) nothing drawn: a box `rotate` measures, as 2htdp\n# counts a crop\'s own edges\n\n\ndef _pll_raw_extent(item):\n """The box an item covers, ignoring its clip."""\n kind = item[0]\n if kind == "poly" or kind == "box":\n return _pll_bounds(item[2])\n if kind == "ellipse":\n _, _, cx, cy, rx, ry, degrees = item[:7]\n rad = _math.radians(degrees)\n cos, sin = _math.cos(rad), _math.sin(rad)\n ex = _math.hypot(rx * cos, ry * sin)\n ey = _math.hypot(rx * sin, ry * cos)\n return (cx - ex, cy - ey, cx + ex, cy + ey)\n if kind == "line":\n x0, y0, x1, y1 = _pll_bounds((item[2:4], item[4:6]))\n # A line is at least a pixel across, as its own box is.\n if x1 - x0 < 1:\n mid = (x0 + x1) / 2\n x0, x1 = mid - 0.5, mid + 0.5\n if y1 - y0 < 1:\n mid = (y0 + y1) / 2\n y0, y1 = mid - 0.5, mid + 0.5\n return (x0, y0, x1, y1)\n m = item[2]\n w, h = (item[5], item[6]) if kind == "text" else (item[3], item[4])\n return _pll_bounds(_pll_corners(m, w, h))\n\n\ndef _pll_extent(item):\n """The box an item covers, within its clip."""\n x0, y0, x1, y1 = _pll_raw_extent(item)\n clip = item[1]\n if clip:\n cx0, cy0, cx1, cy1 = _pll_bounds(clip)\n x0, y0, x1, y1 = max(x0, cx0), max(y0, cy0), min(x1, cx1), min(y1, cy1)\n return (x0, y0, max(x0, x1), max(y0, y1))\n\n\ndef _pll_settle(item):\n """`item` with a clip only where one cuts something off, or None.\n\n A shape wholly inside its clip needs none, and one wholly outside draws\n nothing - so a picture placed inside a scene draws, and compares, as it\n would on its own.\n """\n clip = item[1]\n if clip is None:\n return item\n if not clip:\n return None\n x0, y0, x1, y1 = _pll_raw_extent(item)\n cx0, cy0, cx1, cy1 = _pll_bounds(clip)\n if x0 > cx1 or x1 < cx0 or y0 > cy1 or y1 < cy0:\n return None\n if _pll_inside(clip, ((x0, y0), (x1, y0), (x1, y1), (x0, y1))):\n return item[:1] + (None,) + item[2:]\n return item\n\n\ndef _pll_flatten(image, m=_PLL_IDENTITY):\n """Every shape `image` draws, bottom first, drawn with `m`.\n\n A stack rather than recursion: a picture built in a loop of a thousand\n `beside`s is a thousand levels deep, past Python\'s recursion limit.\n """\n out = []\n stack = [(image, m, None)]\n while stack:\n node, m, clip = stack.pop()\n parts = node._parts()\n if parts is None:\n for item in node._items(m, clip):\n item = _pll_settle(item)\n if item is not None:\n out.append(item)\n continue\n # Pushed last first, so the bottom part is drawn first.\n for child, local, local_clip in reversed(parts):\n child_m = m if local is None else _pll_compose(m, local)\n child_clip = clip\n if local_clip is not None:\n region = tuple(_pll_apply(m, x, y) for x, y in local_clip)\n child_clip = region if clip is None else _pll_convex_and(clip, region)\n stack.append((child, child_m, child_clip))\n return out\n\n\n# -----------------------------------------------------------------------------\n# Comparing what two images draw\n# -----------------------------------------------------------------------------\n\ndef _pll_r(value):\n """A position or a size as `==` compares it: to a hundredth of a pixel."""\n value = round(value, 2)\n return 0.0 if value == 0 else value\n\n\ndef _pll_point_key(p):\n return (_pll_r(p[0]), _pll_r(p[1]))\n\n\ndef _pll_polygon_key(points):\n """The same polygon whichever corner it starts at and whichever way round."""\n pts = []\n for p in points:\n p = _pll_point_key(p)\n if not pts or pts[-1] != p:\n pts.append(p)\n while len(pts) > 1 and pts[0] == pts[-1]:\n pts.pop()\n if not pts:\n return ()\n lowest = min(pts)\n best = None\n for seq in (pts, pts[::-1]):\n for i, p in enumerate(seq):\n if p == lowest:\n candidate = tuple(seq[i:] + seq[:i])\n if best is None or candidate < best:\n best = candidate\n return best\n\n\ndef _pll_item_key(item):\n """What `==` compares of one item."""\n kind = item[0]\n if kind == "poly":\n shape = ("poly", item[3], item[4].key, _pll_polygon_key(item[2]))\n elif kind == "ellipse":\n _, _, cx, cy, rx, ry, degrees, mode, paint = item\n big, small = max(rx, ry), min(rx, ry)\n if big - small < 0.005:\n axis = (0.0, 0.0)\n else:\n rad = _math.radians(degrees if rx >= ry else degrees + 90)\n ax, ay = _pll_r(big * _math.cos(rad)), _pll_r(big * _math.sin(rad))\n # A diameter, so either end names it.\n axis = (ax, ay) if ax > 0 or (ax == 0 and ay > 0) else (_pll_r(-ax), _pll_r(-ay))\n shape = ("ellipse", mode, paint.key, _pll_r(cx), _pll_r(cy), _pll_r(big), _pll_r(small), axis)\n elif kind == "line":\n ends = sorted((_pll_point_key(item[2:4]), _pll_point_key(item[4:6])))\n shape = ("line", item[6].key, tuple(ends))\n else:\n m = item[2]\n w, h = (item[5], item[6]) if kind == "text" else (item[3], item[4])\n corners = tuple(\n _pll_point_key(p) for p in (_pll_apply(m, 0, 0), _pll_apply(m, w, 0), _pll_apply(m, 0, h))\n )\n if kind == "text":\n shape = ("text", item[7].key, item[3], corners)\n elif kind == "bitmap":\n shape = ("bitmap", corners, item[5]._media_type, item[5]._data)\n else:\n shape = ("svg", corners, item[5])\n return (shape, _pll_polygon_key(item[1]) if item[1] else ())\n\n\ndef _pll_visible(item):\n kind = item[0]\n if kind == "box":\n return False\n if kind in ("poly", "ellipse"):\n return item[-1].alpha > 0\n if kind == "line":\n return item[6].alpha > 0\n if kind == "text":\n return item[7].alpha > 0 and item[3] != ""\n return True\n\n\ndef _pll_canonical(items):\n """The items as `==` compares them, in an order fixed by what they draw.\n\n Painting order matters only where two shapes overlap, so `beside(a, b)`\n and an `overlay_xy` that puts the same two side by side draw the same\n thing in different orders. Shapes that do not overlap may be taken in\n either order; of those free to go next, the least is taken, which gives\n every way of drawing the same picture the same list.\n """\n keys = []\n boxes = []\n for item in items:\n if _pll_visible(item):\n keys.append(_pll_item_key(item))\n boxes.append(_pll_extent(item))\n n = len(keys)\n successors = [[] for _ in range(n)]\n waiting = [0] * n\n # Sweep across by left edge, so only shapes that could meet are tried.\n by_left = sorted(range(n), key=lambda i: boxes[i][0])\n for at, i in enumerate(by_left):\n x0, y0, x1, y1 = boxes[i]\n for j in by_left[at + 1:]:\n bx0, by0, bx1, by1 = boxes[j]\n if bx0 >= x1 - 0.005:\n break\n if min(y1, by1) - max(y0, by0) > 0.005 and min(x1, bx1) - max(x0, bx0) > 0.005:\n lower, upper = (i, j) if i < j else (j, i)\n successors[lower].append(upper)\n waiting[upper] += 1\n ready = [(keys[i], i) for i in range(n) if waiting[i] == 0]\n _pll_img_heapq.heapify(ready)\n out = []\n while ready:\n key, i = _pll_img_heapq.heappop(ready)\n out.append(key)\n for j in successors[i]:\n waiting[j] -= 1\n if waiting[j] == 0:\n _pll_img_heapq.heappush(ready, (keys[j], j))\n return tuple(out)\n\n\n# -----------------------------------------------------------------------------\n# Drawing as SVG\n# -----------------------------------------------------------------------------\n\n#: Per interpreter, so two pictures shown in the same panel - one from\n#: before Python was restarted - never share an id.\n_PLL_SVG_ID_PREFIX = "pll" + _pll_img_os.urandom(3).hex()\n_pll_svg_ids = 0\n\n\ndef _pll_svg_id(kind):\n global _pll_svg_ids\n _pll_svg_ids += 1\n return "%s%s%d" % (_PLL_SVG_ID_PREFIX, kind, _pll_svg_ids)\n\n\ndef _pll_n(value):\n """A number for an SVG attribute: to a hundredth, without trailing zeros."""\n value = round(value, 2)\n if value == int(value):\n return "%d" % int(value)\n return ("%.2f" % value).rstrip("0")\n\n\ndef _pll_points(points):\n return " ".join("%s,%s" % (_pll_n(x), _pll_n(y)) for x, y in points)\n\n\ndef _pll_paint(paint, attribute):\n text = \' %s="%s"\' % (attribute, paint.css)\n if paint.alpha < 1:\n text += \' %s-opacity="%s"\' % (attribute, ("%.3f" % paint.alpha).rstrip("0").rstrip(".") or "0")\n return text\n\n\ndef _pll_outline(paint):\n """The pen: 1 pixel, at any scale, as 2htdp\'s is."""\n return \' fill="none"%s stroke-width="1" stroke-miterlimit="10"\' % _pll_paint(paint, "stroke")\n\n\ndef _pll_is_translation(m):\n return m[0] == 1 and m[1] == 0 and m[2] == 0 and m[3] == 1\n\n\ndef _pll_matrix(m):\n return "matrix(%s)" % " ".join(\n ("%.6f" % v).rstrip("0").rstrip(".") if i < 4 else _pll_n(v) for i, v in enumerate(m)\n )\n\n\ndef _pll_axis_rect(points):\n """(x, y, w, h) when the four points are an upright rectangle."""\n if len(points) != 4:\n return None\n (x0, y0), (x1, y1), (x2, y2), (x3, y3) = points\n if (abs(y0 - y1) < 1e-9 and abs(x1 - x2) < 1e-9 and abs(y2 - y3) < 1e-9 and abs(x3 - x0) < 1e-9) or (\n abs(x0 - x1) < 1e-9 and abs(y1 - y2) < 1e-9 and abs(x2 - x3) < 1e-9 and abs(y3 - y0) < 1e-9\n ):\n left, top, right, bottom = _pll_bounds(points)\n return (left, top, right - left, bottom - top)\n return None\n\n\ndef _pll_inset(points, d):\n """The polygon moved `d` inward along every edge, or None if it vanishes.\n\n So a 1-pixel pen centred on it lies just inside the shape: an outline\n stays within its box, and two outlined squares side by side each keep\n their own edge.\n """\n pts = []\n for p in points:\n if not pts or abs(p[0] - pts[-1][0]) > 1e-9 or abs(p[1] - pts[-1][1]) > 1e-9:\n pts.append(p)\n while len(pts) > 1 and abs(pts[0][0] - pts[-1][0]) <= 1e-9 and abs(pts[0][1] - pts[-1][1]) <= 1e-9:\n pts.pop()\n n = len(pts)\n if n < 3:\n return None\n area = _pll_area(pts)\n if abs(area) < 1e-9:\n return None\n turn = 1.0 if area > 0 else -1.0\n edges = []\n for i in range(n):\n (px, py), (qx, qy) = pts[i], pts[(i + 1) % n]\n dx, dy = qx - px, qy - py\n length = _math.hypot(dx, dy)\n nx, ny = -turn * dy / length, turn * dx / length\n edges.append((px + nx * d, py + ny * d, dx, dy))\n out = []\n for i in range(n):\n ax, ay, adx, ady = edges[i - 1]\n bx, by, bdx, bdy = edges[i]\n den = adx * bdy - ady * bdx\n if abs(den) < 1e-12 * max(1.0, _math.hypot(adx, ady) * _math.hypot(bdx, bdy)):\n out.append((bx, by))\n continue\n t = ((bx - ax) * bdy - (by - ay) * bdx) / den\n out.append((ax + t * adx, ay + t * ady))\n inner = _pll_area(out)\n if inner * area <= 0 or abs(inner) < 1e-6:\n return None\n for i in range(n):\n dx = out[(i + 1) % n][0] - out[i][0]\n dy = out[(i + 1) % n][1] - out[i][1]\n if dx * edges[i][2] + dy * edges[i][3] <= 0:\n return None\n return out\n\n\ndef _pll_svg_poly(item):\n _, _, points, mode, paint = item\n if mode == "outline":\n rect = _pll_axis_rect(points)\n if rect is not None and rect[2] > 1 and rect[3] > 1:\n x, y, w, h = rect\n return \'<rect x="%s" y="%s" width="%s" height="%s"%s/>\' % (\n _pll_n(x + 0.5), _pll_n(y + 0.5), _pll_n(w - 1), _pll_n(h - 1), _pll_outline(paint),\n )\n inner = None if rect is not None else _pll_inset(points, 0.5)\n if inner is not None:\n return \'<polygon points="%s"%s/>\' % (_pll_points(inner), _pll_outline(paint))\n # Too small to have an inside: the pen fills it.\n rect = _pll_axis_rect(points)\n if rect is not None:\n return \'<rect x="%s" y="%s" width="%s" height="%s"%s/>\' % (\n _pll_n(rect[0]), _pll_n(rect[1]), _pll_n(rect[2]), _pll_n(rect[3]), _pll_paint(paint, "fill"),\n )\n return \'<polygon points="%s"%s/>\' % (_pll_points(points), _pll_paint(paint, "fill"))\n\n\ndef _pll_svg_ellipse(item):\n _, _, cx, cy, rx, ry, degrees, mode, paint = item\n style = _pll_paint(paint, "fill")\n if mode == "outline" and rx > 0.5 and ry > 0.5:\n rx, ry = rx - 0.5, ry - 0.5\n style = _pll_outline(paint)\n if abs(rx - ry) < 1e-9:\n return \'<circle cx="%s" cy="%s" r="%s"%s/>\' % (_pll_n(cx), _pll_n(cy), _pll_n(rx), style)\n turned = ""\n if abs(degrees % 180) > 1e-9:\n turned = \' transform="rotate(%s %s %s)"\' % (_pll_n(degrees), _pll_n(cx), _pll_n(cy))\n return \'<ellipse cx="%s" cy="%s" rx="%s" ry="%s"%s%s/>\' % (\n _pll_n(cx), _pll_n(cy), _pll_n(rx), _pll_n(ry), turned, style,\n )\n\n\ndef _pll_svg_line(item):\n _, _, x1, y1, x2, y2, paint = item\n if abs(x1 - x2) < 1e-9 and abs(y1 - y2) < 1e-9:\n return ""\n return \'<line x1="%s" y1="%s" x2="%s" y2="%s"%s stroke-width="1"/>\' % (\n _pll_n(x1), _pll_n(y1), _pll_n(x2), _pll_n(y2), _pll_paint(paint, "stroke"),\n )\n\n\n#: Text is set in a monospace font, whose every character is the same width,\n#: so its size is known rather than guessed; `textLength` holds it to that\n#: width whichever monospace font the viewer has.\n_PLL_TEXT_ADVANCE = 0.6\n_PLL_TEXT_HEIGHT = 1.2\n_PLL_TEXT_BASELINE = 0.92\n\n\ndef _pll_text_cells(text):\n """How many character cells `text` takes: wide characters two, accents none."""\n cells = 0\n for ch in text:\n if _pll_img_unicodedata.combining(ch) or _pll_img_unicodedata.category(ch) in ("Mn", "Me", "Cf"):\n continue\n cells += 2 if _pll_img_unicodedata.east_asian_width(ch) in ("W", "F") else 1\n return cells\n\n\ndef _pll_svg_text(item):\n _, _, m, text, size, w, h, paint = item\n if w <= 0:\n return ""\n attrs = (\n \'font-family="monospace" font-size="%s" textLength="%s" \'\n \'lengthAdjust="spacingAndGlyphs" xml:space="preserve"%s\'\n ) % (_pll_n(size), _pll_n(w), _pll_paint(paint, "fill"))\n baseline = size * _PLL_TEXT_BASELINE\n if _pll_is_translation(m):\n return \'<text x="%s" y="%s" %s>%s</text>\' % (\n _pll_n(m[4]), _pll_n(m[5] + baseline), attrs, _pll_xml_escape(text),\n )\n return \'<text x="0" y="%s" %s transform="%s">%s</text>\' % (\n _pll_n(baseline), attrs, _pll_matrix(m), _pll_xml_escape(text),\n )\n\n\ndef _pll_placed(m):\n """Attributes that put something drawn at (0, 0) where `m` puts it."""\n if _pll_is_translation(m):\n return \' x="%s" y="%s"\' % (_pll_n(m[4]), _pll_n(m[5]))\n return \' x="0" y="0" transform="%s"\' % _pll_matrix(m)\n\n\ndef _pll_svg_markup(item):\n m, markup = item[2], item[5]\n if m == _PLL_IDENTITY:\n return markup\n return \'<g transform="%s">%s</g>\' % (_pll_matrix(m), markup)\n\n\ndef _pll_render(items):\n """The SVG elements that draw `items`, with whatever `<defs>` they need.\n\n A picture loaded once and used many times is embedded once and drawn\n with `<use>`; shapes that share a clip share one group.\n """\n uses = {}\n for item in items:\n if item[0] == "bitmap":\n uses[id(item[5])] = uses.get(id(item[5]), 0) + 1\n defs = []\n body = []\n shared = {}\n clip = None\n for item in items:\n kind = item[0]\n if kind == "box":\n continue\n if kind == "poly":\n markup = _pll_svg_poly(item) if item[4].alpha > 0 else ""\n elif kind == "ellipse":\n markup = _pll_svg_ellipse(item) if item[8].alpha > 0 else ""\n elif kind == "line":\n markup = _pll_svg_line(item) if item[6].alpha > 0 else ""\n elif kind == "text":\n markup = _pll_svg_text(item) if item[7].alpha > 0 else ""\n elif kind == "bitmap":\n picture = item[5]\n size = \' width="%s" height="%s" preserveAspectRatio="none"\' % (_pll_n(item[3]), _pll_n(item[4]))\n if uses[id(picture)] == 1:\n markup = \'<image%s%s href="%s"/>\' % (_pll_placed(item[2]), size, picture._href())\n else:\n if id(picture) not in shared:\n shared[id(picture)] = _pll_svg_id("i")\n defs.append(\'<image id="%s"%s href="%s"/>\' % (shared[id(picture)], size, picture._href()))\n markup = \'<use href="#%s"%s/>\' % (shared[id(picture)], _pll_placed(item[2]))\n else:\n markup = _pll_svg_markup(item)\n if not markup:\n continue\n if item[1] != clip:\n if clip is not None:\n body.append("</g>")\n clip = item[1]\n if clip is not None:\n cid = _pll_svg_id("c")\n defs.append(\'<clipPath id="%s"><polygon points="%s"/></clipPath>\' % (cid, _pll_points(clip)))\n body.append(\'<g clip-path="url(#%s)">\' % cid)\n body.append(markup)\n if clip is not None:\n body.append("</g>")\n return ("<defs>%s</defs>" % "".join(defs) if defs else "") + "".join(body)\n\n\n# -----------------------------------------------------------------------------\n# Image base class\n# -----------------------------------------------------------------------------\n\nclass Image:\n """A picture: the type of everything `circle`, `beside`, `text`, ... make.\n\n Use it in annotations - `def badge(n: int) -> Image:` - and make pictures\n with the shape functions, not with `Image()` itself. Two images are `==`\n when they draw the same shapes in the same places in the same colours.\n """\n\n _w = 0.0\n _h = 0.0\n #: The flattened drawing and what `==` compares, worked out once each.\n _flat = None\n _key = None\n\n def __init__(self, *args, **kwargs):\n raise TypeError(\n "Image is the type of pictures, not a way to make one. Make a "\n \'picture with a shape - circle(20, "solid", "red"), rectangle, \'\n "text, ... - and put pictures together with beside, above and overlay."\n )\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 _parts(self):\n """`(child, transform, clip)` for each child, bottom first; None for a shape."""\n return None\n\n def _items(self, m, clip):\n """A shape\'s items, drawn with `m` and clipped to `clip`."""\n return ()\n\n def _drawing(self):\n if self._flat is None:\n self._flat = _pll_flatten(self)\n return self._flat\n\n def to_svg(self):\n # At least 1: an `<svg>` of zero width renders as nothing at all.\n w = max(1, _pll_px(self._w))\n h = max(1, _pll_px(self._h))\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, _pll_render(self._drawing()))\n\n def _pll_image_data(self):\n return {\n "type": "svg",\n "width": _pll_px(self._w),\n "height": _pll_px(self._h),\n "data": self.to_svg(),\n }\n\n def _pll_comparable(self):\n if self._key is None:\n self._key = (_pll_r(self._w), _pll_r(self._h), _pll_canonical(self._drawing()))\n return self._key\n\n def __eq__(self, other):\n if not isinstance(other, Image):\n return NotImplemented\n return self is other or self._pll_comparable() == other._pll_comparable()\n\n def __hash__(self):\n return hash(self._pll_comparable())\n\n # Nothing about an image changes, so a copy is the image itself - and\n # `deepcopy` would otherwise recurse down a deep picture.\n def __copy__(self):\n return self\n\n def __deepcopy__(self, memo):\n return self\n\n def __repr__(self):\n return "<Image %dx%d>" % (_pll_px(self._w), _pll_px(self._h))\n\n def __add__(self, other):\n """`a + b` on images, which looks plausible and is not a thing.\n\n Without this, Python\'s own message names the private classes:\n "unsupported operand type(s) for +: \'_Rectangle\' and \'_Rectangle\'".\n """\n raise TypeError(\n "Images cannot be joined with `+`. Use `beside(a, b)` to put them "\n "side by side, `above(a, b)` to stack them, or `overlay(a, b)` to "\n "put one on top of the other."\n )\n\n __radd__ = __add__\n\n\n# -----------------------------------------------------------------------------\n# Shapes\n# -----------------------------------------------------------------------------\n\nclass _Rectangle(Image):\n def __init__(self, width, height, mode, paint):\n self._w = float(width)\n self._h = float(height)\n self._mode = mode\n self._paint = paint\n\n def _items(self, m, clip):\n return (("poly", clip, _pll_corners(m, self._w, self._h), self._mode, self._paint),)\n\n\ndef _pll_ellipse_item(m, clip, cx, cy, rx, ry, mode, paint):\n """The ellipse with radii `rx`, `ry` about (cx, cy), drawn with `m`.\n\n What a transform makes of an ellipse is another ellipse, whose radii\n and turn are the singular values and rotation of the transform\'s linear\n part (a closed form for 2x2).\n """\n x, y = _pll_apply(m, cx, cy)\n a00, a10 = m[0] * rx, m[1] * rx\n a01, a11 = m[2] * ry, m[3] * ry\n e, f = (a00 + a11) / 2, (a00 - a11) / 2\n g, h = (a10 + a01) / 2, (a10 - a01) / 2\n q, r = _math.hypot(e, h), _math.hypot(f, g)\n turn = (_math.atan2(h, e) + _math.atan2(g, f)) / 2\n return ("ellipse", clip, x, y, q + r, abs(q - r), _math.degrees(turn), mode, paint)\n\n\nclass _Circle(Image):\n def __init__(self, radius, mode, paint):\n self._radius = float(radius)\n self._w = self._h = self._radius * 2\n self._mode = mode\n self._paint = paint\n\n def _items(self, m, clip):\n r = self._radius\n return (_pll_ellipse_item(m, clip, r, r, r, r, self._mode, self._paint),)\n\n\nclass _Ellipse(Image):\n def __init__(self, width, height, mode, paint):\n self._w = float(width)\n self._h = float(height)\n self._mode = mode\n self._paint = paint\n\n def _items(self, m, clip):\n rx, ry = self._w / 2, self._h / 2\n return (_pll_ellipse_item(m, clip, rx, ry, rx, ry, self._mode, self._paint),)\n\n\nclass _Polygon(Image):\n """A polygon, measured by its points: its box is its real extent."""\n\n def __init__(self, points, mode, paint):\n left, top, right, bottom = _pll_bounds(points)\n self._points = tuple((x - left, y - top) for x, y in points)\n self._w = right - left\n self._h = bottom - top\n self._mode = mode\n self._paint = paint\n\n def _items(self, m, clip):\n return (("poly", clip, tuple(_pll_apply(m, x, y) for x, y in self._points), self._mode, self._paint),)\n\n\nclass _Line(Image):\n """A line `(dx, dy)` long, in a box at least a pixel each way."""\n\n def __init__(self, dx, dy, paint):\n self._dx = float(dx)\n self._dy = float(dy)\n self._w = max(abs(self._dx), 1.0)\n self._h = max(abs(self._dy), 1.0)\n self._paint = paint\n\n def _items(self, m, clip):\n x1 = (0.0 if self._dx >= 0 else -self._dx) + (self._w - abs(self._dx)) / 2\n y1 = (0.0 if self._dy >= 0 else -self._dy) + (self._h - abs(self._dy)) / 2\n (ax, ay), (bx, by) = _pll_apply(m, x1, y1), _pll_apply(m, x1 + self._dx, y1 + self._dy)\n return (("line", clip, ax, ay, bx, by, self._paint),)\n\n\nclass _Text(Image):\n """Text in a monospace font, so its size is exact: spaces are kept."""\n\n def __init__(self, text, size, paint):\n self._text = text\n self._size = float(size)\n self._w = _pll_text_cells(text) * self._size * _PLL_TEXT_ADVANCE\n self._h = self._size * _PLL_TEXT_HEIGHT\n self._paint = paint\n\n def _items(self, m, clip):\n return (("text", clip, m, self._text, self._size, self._w, self._h, self._paint),)\n\n\nclass _Drawing(Image):\n """A ready-made SVG of a known size - a chart - as an image like any other."""\n\n def __init__(self, markup, width, height):\n self._markup = markup\n self._w = float(width)\n self._h = float(height)\n\n def _items(self, m, clip):\n return (("svg", clip, m, self._w, self._h, self._markup),)\n\n def to_svg(self):\n return self._markup\n\n\nclass _PllBox(Image):\n """Nothing to see: the edges of a crop or a scene, for `rotate` to measure."""\n\n def __init__(self, width, height):\n self._w = float(width)\n self._h = float(height)\n\n def _items(self, m, clip):\n return (("box", clip, _pll_corners(m, self._w, self._h)),)\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\n#: Pixels in one of each absolute CSS unit.\n_PLL_SVG_UNITS = {\n "": 1.0, "px": 1.0, "pt": 4 / 3, "pc": 16.0, "in": 96.0,\n "cm": 96 / 2.54, "mm": 96 / 25.4, "q": 96 / 101.6,\n}\n_PLL_SVG_TAG_RE = _pll_img_re.compile(r"<svg\\b([^>]*)>", _pll_img_re.I)\n_PLL_SVG_ATTR_RE = _pll_img_re.compile(r"""([\\w:.-]+)\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')""")\n_PLL_SVG_LENGTH_RE = _pll_img_re.compile(r"^\\s*([0-9]*\\.?[0-9]+(?:e[+-]?\\d+)?)\\s*([a-z]*)\\s*$", _pll_img_re.I)\n\n\ndef _pll_svg_size(data):\n """An SVG\'s size, from its own `<svg>` tag: `width` and `height`, or the viewBox.\n\n Read from that tag only, so a `stroke-width` or a child\'s `width` is not\n taken for the picture\'s, and with each unit turned into pixels. A size\n in `%` or `em` says nothing on its own, so the viewBox decides.\n """\n try:\n head = data[:65536].decode("utf-8", "replace")\n except Exception:\n return None\n # Past the prologue: comments can hold anything, an `<svg` included.\n head = _pll_img_re.sub(r"<!--.*?-->", "", head, flags=_pll_img_re.S)\n tag = _PLL_SVG_TAG_RE.search(head)\n if tag is None:\n return None\n attrs = {}\n for name, double, single in _PLL_SVG_ATTR_RE.findall(tag.group(1)):\n attrs[name.lower()] = double if double or not single else single\n\n def length(name):\n found = _PLL_SVG_LENGTH_RE.match(attrs.get(name, ""))\n if not found or found.group(2).lower() not in _PLL_SVG_UNITS:\n return None\n return float(found.group(1)) * _PLL_SVG_UNITS[found.group(2).lower()]\n\n w, h = length("width"), length("height")\n box = None\n numbers = _pll_img_re.split(r"[\\s,]+", attrs.get("viewbox", "").strip())\n if len(numbers) == 4:\n try:\n box = (float(numbers[2]), float(numbers[3]))\n except ValueError:\n box = None\n if w and h:\n return (w, h)\n if box and box[0] > 0 and box[1] > 0:\n # One side given: the other follows the viewBox\'s shape.\n if w:\n return (w, w * box[1] / box[0])\n if h:\n return (h * box[0] / box[1], h)\n return box\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#: The largest picture `load_image` reads, from a file or an address: the\n#: limit on a file beside the program.\n_PLL_MAX_PICTURE_BYTES = 2 * 1024 * 1024\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 def __init__(self, width, height, media_type, data, source):\n self._w = float(width)\n self._h = float(height)\n self._media_type = media_type\n self._data = data\n self._source = source\n self._encoded = None\n\n def _href(self):\n if self._encoded is None:\n self._encoded = "data:%s;base64,%s" % (\n self._media_type,\n _pll_img_b64.b64encode(self._data).decode("ascii"),\n )\n return self._encoded\n\n def _items(self, m, clip):\n return (("bitmap", clip, m, self._w, self._h, self),)\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, up to 2 MB.\n """\n data = _pll_read_source(source, "load_image", binary=True, limit=_PLL_MAX_PICTURE_BYTES)\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\ndef _pll_figure_image(figure):\n """A matplotlib figure as a picture: a PNG at twice its size, for a sharp\n screen, shown at its own size."""\n import io as _pll_img_io\n\n out = _pll_img_io.BytesIO()\n figure.savefig(out, format="png", dpi=figure.dpi * 2)\n width, height = figure.get_size_inches() * figure.dpi\n return _LoadedImage(width, height, "image/png", out.getvalue(), "a figure")\n\n\ndef _pll_show_figure(figure):\n """Show a figure where the program\'s pictures go, and close it, so it is\n shown once."""\n _pll_push(_pll_extract_display(_pll_figure_image(figure)))\n pyplot = _pll_img_sys.modules.get("matplotlib.pyplot")\n if pyplot is not None:\n pyplot.close(figure)\n\n\n# -----------------------------------------------------------------------------\n# Combinators\n# -----------------------------------------------------------------------------\n\n_PLL_X_PLACES = ("left", "center", "middle", "right")\n_PLL_Y_PLACES = ("top", "center", "middle", "bottom")\n\n#: How each aligned combinator is written, for the message that says so.\n_PLL_ALIGNED_EXAMPLES = {\n "beside_align": \'beside_align("top", image1, image2)\',\n "above_align": \'above_align("left", image1, image2)\',\n "overlay_align": \'overlay_align("left", "top", image1, image2)\',\n "underlay_align": \'underlay_align("left", "top", image1, image2)\',\n}\n\n\ndef _pll_check_place(place, allowed, who, param):\n if isinstance(place, str) and place in allowed:\n return place\n if isinstance(place, Image):\n raise TypeError(\n "%s takes the alignment first, then the images: write %s."\n % (who, _PLL_ALIGNED_EXAMPLES[who])\n )\n suggestion = _pll_closest_name(place.lower(), allowed) if isinstance(place, str) else None\n raise ValueError(\n "%s\'s `%s`%s should be \\"%s\\", \\"%s\\" or \\"%s\\", but it is %s.%s"\n % (\n who,\n param,\n _pll_where(who, param),\n allowed[0],\n allowed[1],\n allowed[3],\n _pll_describe(place),\n \' Did you mean "%s"?\' % suggestion if suggestion else "",\n )\n )\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\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 self._w = sum(c._w for c in self._children)\n self._h = max((c._h for c in self._children), default=0.0)\n\n def _parts(self):\n parts = []\n x = 0.0\n for child in self._children:\n parts.append((child, _pll_translate(x, _pll_offset(self._y_place, self._h, child._h)), None))\n x += child._w\n return parts\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 self._w = max((c._w for c in self._children), default=0.0)\n self._h = sum(c._h for c in self._children)\n\n def _parts(self):\n parts = []\n y = 0.0\n for child in self._children:\n parts.append((child, _pll_translate(_pll_offset(self._x_place, self._w, child._w), y), None))\n y += child._h\n return parts\n\n\nclass _Overlay(Image):\n """First child on top. Children aligned by (`x_place`, `y_place`)."""\n\n def __init__(self, children, x_place="center", y_place="center"):\n self._children = list(children)\n self._x_place = x_place\n self._y_place = y_place\n self._w = max(c._w for c in self._children)\n self._h = max(c._h for c in self._children)\n\n def _parts(self):\n return [\n (\n child,\n _pll_translate(\n _pll_offset(self._x_place, self._w, child._w),\n _pll_offset(self._y_place, self._h, child._h),\n ),\n None,\n )\n for child in reversed(self._children)\n ]\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: both children move right / down by however far the\n box grew, so the picture\'s own top-left stays at (0, 0).\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 left, top = min(0.0, self._dx), min(0.0, self._dy)\n self._w = max(first._w, self._dx + second._w) - left\n self._h = max(first._h, self._dy + second._h) - top\n\n def _parts(self):\n shift_x = -min(0.0, self._dx)\n shift_y = -min(0.0, self._dy)\n first = (self._first, _pll_translate(shift_x, shift_y), None)\n second = (self._second, _pll_translate(shift_x + self._dx, shift_y + self._dy), None)\n return [second, first] if self._first_on_top else [first, second]\n\n\ndef _pll_box_region(w, h):\n return ((0.0, 0.0), (w, 0.0), (w, h), (0.0, h))\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 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 def _parts(self):\n return [\n (self._image, _pll_translate(-self._x, -self._y), _pll_box_region(self._w, self._h)),\n (_PllBox(self._w, self._h), None, None),\n ]\n\n\nclass _PlaceImage(Image):\n """`image` centred 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 self._w = scene._w\n self._h = scene._h\n\n def _parts(self):\n region = _pll_box_region(self._w, self._h)\n at = _pll_translate(self._cx - self._image._w / 2.0, self._cy - self._image._h / 2.0)\n return [\n (self._scene, None, region),\n (self._image, at, region),\n (_PllBox(self._w, self._h), None, None),\n ]\n\n\nclass _Frame(Image):\n """`image` with a thin black outline around its bounding box."""\n\n def __init__(self, image):\n self._image = image\n self._w = image._w\n self._h = image._h\n\n def _parts(self):\n return [\n (self._image, None, None),\n (_Rectangle(self._w, self._h, "outline", _PLL_BLACK), None, None),\n ]\n\n\nclass _Rotate(Image):\n """`image` turned `angle` degrees counter-clockwise (HtDP convention).\n\n Its box is the turned shapes\' own, as 2htdp\'s is: a circle stays as wide\n as it was, and turning a picture again and again does not grow it.\n """\n\n def __init__(self, angle, image):\n self._angle = angle % 360.0\n if self._angle % 90 == 0:\n # Exactly, so a quarter turn does not leave 6e-17 behind.\n cos, sin = ((1, 0), (0, 1), (-1, 0), (0, -1))[int(self._angle // 90)]\n else:\n rad = _math.radians(self._angle)\n cos, sin = _math.cos(rad), _math.sin(rad)\n # Counter-clockwise on a screen, where y grows downward.\n turn = (float(cos), float(-sin), float(sin), float(cos), 0.0, 0.0)\n boxes = [_pll_extent(item) for item in _pll_flatten(image, turn)]\n if boxes:\n left = min(b[0] for b in boxes)\n top = min(b[1] for b in boxes)\n self._w = max(b[2] for b in boxes) - left\n self._h = max(b[3] for b in boxes) - top\n else:\n left = top = 0.0\n self._w = self._h = 0.0\n self._image = image\n self._turn = _pll_compose(_pll_translate(-left, -top), turn)\n\n def _parts(self):\n return [(self._image, self._turn, None)]\n\n\nclass _Scale(Image):\n def __init__(self, factor, image):\n self._factor = float(factor)\n self._image = image\n self._w = image._w * self._factor\n self._h = image._h * self._factor\n\n def _parts(self):\n f = self._factor\n return [(self._image, (f, 0.0, 0.0, f, 0.0, 0.0), None)]\n\n\nclass _Flip(Image):\n def __init__(self, image, horizontal):\n self._image = image\n self._horizontal = horizontal\n self._w = image._w\n self._h = image._h\n\n def _parts(self):\n if self._horizontal:\n return [(self._image, (-1.0, 0.0, 0.0, 1.0, self._w, 0.0), None)]\n return [(self._image, (1.0, 0.0, 0.0, -1.0, 0.0, self._h), None)]\n\n\n# -----------------------------------------------------------------------------\n# Public API (exported into user globals by the install step)\n# -----------------------------------------------------------------------------\n\ndef circle(radius, mode, color):\n """A circle: `mode` is "solid" or "outline"."""\n radius = _pll_check_size(radius, "circle", "radius")\n return _Circle(radius, _pll_check_mode(mode, "circle"), _pll_check_color(color, "circle"))\n\n\ndef square(side, mode, color):\n """A square with sides `side` long."""\n side = _pll_check_size(side, "square", "side")\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 """A rectangle `width` across and `height` high."""\n width = _pll_check_size(width, "rectangle", "width")\n height = _pll_check_size(height, "rectangle", "height")\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 """An ellipse `width` across and `height` high."""\n width = _pll_check_size(width, "ellipse", "width")\n height = _pll_check_size(height, "ellipse", "height")\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 """An equilateral triangle pointing up, with sides `side` long."""\n side = _pll_check_size(side, "triangle", "side")\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 """A right triangle: legs `width` along the bottom and `height` up the left."""\n width = _pll_check_size(width, "right_triangle", "width")\n height = _pll_check_size(height, "right_triangle", "height")\n mode = _pll_check_mode(mode, "right_triangle")\n paint = _pll_check_color(color, "right_triangle")\n # The right angle at the bottom left, as 2htdp draws it.\n return _Polygon([(0.0, 0.0), (0.0, height), (width, height)], mode, paint)\n\n\ndef regular_polygon(side, sides, mode, color):\n """A regular polygon with `sides` sides, each `side` long."""\n side = _pll_check_size(side, "regular_polygon", "side")\n sides = _pll_check_count(sides, "regular_polygon", "sides", 3)\n mode = _pll_check_mode(mode, "regular_polygon")\n paint = _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, ...)` would be a 57x57 diamond instead of a\n # 40x40 square. Half a step of rotation fixes the even cases and leaves\n # 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 points.append((radius + radius * _math.cos(angle), radius + radius * _math.sin(angle)))\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, paint)\n\n\ndef _pll_star_points(side, count, step):\n """The outline of the star polygon {count/step} around a regular polygon.\n\n Its points are the polygon\'s corners; between them, where the lines\n joining every `step`-th corner cross, at R*cos(pi*step/count) /\n cos(pi*(step-1)/count) from the centre.\n """\n radius = side / (2 * _math.sin(_math.pi / count))\n inner = radius * _math.cos(_math.pi * step / count) / _math.cos(_math.pi * (step - 1) / count)\n points = []\n for i in range(count * 2):\n r = radius if i % 2 == 0 else inner\n angle = -_math.pi / 2.0 + i * _math.pi / count\n points.append((radius + r * _math.cos(angle), radius + r * _math.sin(angle)))\n return points\n\n\ndef star(side, mode, color):\n """A five-pointed star, its points the corners of a pentagon with sides `side` long."""\n side = _pll_check_size(side, "star", "side")\n mode = _pll_check_mode(mode, "star")\n paint = _pll_check_color(color, "star")\n return _Polygon(_pll_star_points(side, 5, 2), mode, paint)\n\n\ndef star_polygon(side, points_count, step, mode, color):\n """A star with `points_count` points, joining every `step`-th corner of a\n regular polygon with sides `side` long: (40, 5, 2, ...) is the usual star."""\n side = _pll_check_size(side, "star_polygon", "side")\n count = _pll_check_count(points_count, "star_polygon", "points_count", 3)\n step = _pll_check_count(step, "star_polygon", "step", 1)\n if step * 2 >= count:\n raise ValueError(\n "star_polygon\'s `step`%s has to be less than half of `points_count`, "\n "but it is %d with %d points - joining every %s corner of %d makes no star.%s"\n % (\n _pll_where("star_polygon", "step"),\n step,\n count,\n _pll_ordinal(step - 1),\n count,\n _pll_contract("star_polygon"),\n )\n )\n mode = _pll_check_mode(mode, "star_polygon")\n paint = _pll_check_color(color, "star_polygon")\n return _Polygon(_pll_star_points(side, count, step), mode, paint)\n\n\ndef line(dx, dy, color):\n """A line from its top-left corner going `dx` across and `dy` down."""\n dx = _pll_check_number(dx, "line", "dx")\n dy = _pll_check_number(dy, "line", "dy")\n return _Line(dx, dy, _pll_check_color(color, "line"))\n\n\ndef text(value, size, color):\n """`value` written in letters `size` pixels high."""\n if not isinstance(value, str):\n raise TypeError(\n "text\'s `value` (the 1st argument) must be a string, but it is %s."\n " The arguments are: text(value, size, color)." % _pll_describe(value)\n )\n size = _pll_check_positive(size, "text", "size")\n return _Text(value, size, _pll_check_color(color, "text"))\n\n\ndef beside(*images):\n """The images side by side, left to right, lined up on their centres."""\n return _Beside(_pll_check_images(images, "beside"))\n\n\ndef above(*images):\n """The images one above the other, top to bottom, lined up on their centres."""\n return _Above(_pll_check_images(images, "above"))\n\n\ndef overlay(*images):\n """The images on top of one another, centred; the first is on top."""\n return _Overlay(_pll_check_images(images, "overlay", least=1))\n\n\ndef underlay(*images):\n """Like overlay, but the first image is on the bottom."""\n return _Overlay(list(reversed(_pll_check_images(images, "underlay", least=1))))\n\n\ndef beside_align(y_place, *images):\n """Like `beside`, lined up by "top", "center" or "bottom"."""\n return _Beside(\n _pll_check_images(images, "beside_align", 1),\n _pll_check_place(y_place, _PLL_Y_PLACES, "beside_align", "y_place"),\n )\n\n\ndef above_align(x_place, *images):\n """Like `above`, lined up by "left", "center" or "right"."""\n return _Above(\n _pll_check_images(images, "above_align", 1),\n _pll_check_place(x_place, _PLL_X_PLACES, "above_align", "x_place"),\n )\n\n\ndef overlay_align(x_place, y_place, *images):\n """Like `overlay`, with both ways of lining up given."""\n x_place = _pll_check_place(x_place, _PLL_X_PLACES, "overlay_align", "x_place")\n y_place = _pll_check_place(y_place, _PLL_Y_PLACES, "overlay_align", "y_place")\n return _Overlay(_pll_check_images(images, "overlay_align", 2, least=1), x_place, y_place)\n\n\ndef underlay_align(x_place, y_place, *images):\n """Like `overlay_align`, but the first image is on the bottom."""\n x_place = _pll_check_place(x_place, _PLL_X_PLACES, "underlay_align", "x_place")\n y_place = _pll_check_place(y_place, _PLL_Y_PLACES, "underlay_align", "y_place")\n return _Overlay(\n list(reversed(_pll_check_images(images, "underlay_align", 2, least=1))), x_place, y_place\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 _pll_check_image(image1, "overlay_xy", 0)\n dx = _pll_check_number(dx, "overlay_xy", "dx")\n dy = _pll_check_number(dy, "overlay_xy", "dy")\n _pll_check_image(image2, "overlay_xy", 3)\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 _pll_check_image(image1, "underlay_xy", 0)\n dx = _pll_check_number(dx, "underlay_xy", "dx")\n dy = _pll_check_number(dy, "underlay_xy", "dy")\n _pll_check_image(image2, "underlay_xy", 3)\n return _LayeredXY(image1, dx, dy, image2, first_on_top=False)\n\n\ndef place_image(image, x, y, scene):\n """Put `image`\'s *centre* at (x, y) on `scene`, cropped to the scene."""\n _pll_check_image(image, "place_image", 0)\n x = _pll_check_number(x, "place_image", "x")\n y = _pll_check_number(y, "place_image", "y")\n _pll_check_image(scene, "place_image", 3)\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 x = _pll_check_number(x, "crop", "x")\n y = _pll_check_number(y, "crop", "y")\n width = _pll_check_size(width, "crop", "width")\n height = _pll_check_size(height, "crop", "height")\n _pll_check_image(image, "crop", 4)\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(_pll_check_image(image, "frame", 0))\n\n\ndef empty_scene(width, height):\n """A blank white scene with an outline, to use with `place_image`."""\n width = _pll_check_size(width, "empty_scene", "width")\n height = _pll_check_size(height, "empty_scene", "height")\n return _Frame(_Rectangle(width, height, "solid", _PLL_WHITE))\n\n\ndef rotate(angle, image):\n """`image` turned `angle` degrees counter-clockwise."""\n _pll_check_order(angle, image, "rotate", "angle")\n angle = _pll_check_number(angle, "rotate", "angle")\n return _Rotate(angle, _pll_check_image(image, "rotate", 1))\n\n\ndef scale(factor, image):\n """`image` made `factor` times bigger (or smaller, below 1)."""\n _pll_check_order(factor, image, "scale", "factor")\n factor = _pll_check_positive(factor, "scale", "factor")\n return _Scale(factor, _pll_check_image(image, "scale", 1))\n\n\ndef flip_horizontal(image):\n """`image` mirrored left to right."""\n return _Flip(_pll_check_image(image, "flip_horizontal", 0), horizontal=True)\n\n\ndef flip_vertical(image):\n """`image` mirrored top to bottom."""\n return _Flip(_pll_check_image(image, "flip_vertical", 0), horizontal=False)\n\n\ndef image_width(image):\n """How many pixels across `image` is."""\n return _pll_px(_pll_check_image(image, "image_width", 0)._w)\n\n\ndef image_height(image):\n """How many pixels high `image` is."""\n return _pll_px(_pll_check_image(image, "image_height", 0)._h)\n\n\nempty_image = _Rectangle(0, 0, "solid", _PLL_TRANSPARENT)\n\n\n# Names exported into user globals by the install step. 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\n\n# Argument names, read off the functions above rather than written out a\n# second time, so `_pll_where` and `_pll_contract` cannot fall out of step\n# with the contracts they quote. `*images` is left out: a function that\n# takes any number of them has no fixed positions to name. Each function\n# says it is `pll.image`\'s, so `help(circle)` does.\nfor _pll_exported in PLL_IMAGE_EXPORTS:\n _pll_value = globals()[_pll_exported]\n if not callable(_pll_value) or isinstance(_pll_value, type):\n continue\n _pll_value.__module__ = "pll.image"\n _PLL_PARAMS[_pll_exported] = tuple(\n _name\n for _name in _pll_value.__code__.co_varnames[: _pll_value.__code__.co_argcount]\n )\ndel _pll_exported, _pll_value\n';
939
222
 
940
- PLL_TABLE_EXPORTS = [
941
- "Table",
942
- "table",
943
- "table_from_columns",
944
- ]
945
- `;
223
+ // src/common/tableLib.py
224
+ 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# t.add_row(row) one more row at the end\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) -> Row, a dict\n# t.rows() -> list[Row]\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 images, which display and combine like any other). The\n# set 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\nimport numbers as _pll_tbl_numbers\nimport sys as _pll_tbl_sys\n\n#: Rows `repr(table)` shows before truncating, few enough not to bury the\n#: rest of a message. A failed `==` between tables says where they differ\n#: (`_pll_compare_tables`), so the rows that matter are always shown.\n_REPR_ROWS = 6\n\n#: The most of a table\'s text the panel is given for Save CSV.\n_PLL_CSV_LIMIT = 8 * 1024 * 1024\n\n\n# -----------------------------------------------------------------------------\n# Cell formatting (for HTML display)\n# -----------------------------------------------------------------------------\n\ndef _format_cell(value):\n """A cell as the panel and the command line show it: as Python prints it.\n\n A float is its `repr`, every digit of it - `12999.99`, not a rounded\n `13000` - so what is shown is what a test compares. A missing value\n (None) is blank.\n """\n if value is None:\n return ""\n if isinstance(value, str):\n return value\n if isinstance(value, bool):\n return "True" if value else "False"\n if isinstance(value, float):\n return repr(float(value))\n return str(value) if isinstance(value, _pll_tbl_numbers.Number) else repr(value)\n\n\ndef _pll_is_number(value):\n """A number to count with: an int or a float, numpy\'s too, not a bool."""\n return isinstance(value, _pll_tbl_numbers.Real) and not isinstance(value, bool)\n\n\ndef _pll_student_level():\n """The level of the student\'s code that called into the library.\n\n Read from the namespace of the nearest of their frames, as the\n strict-number check reads it, so a helper module at another level\n gets its own.\n """\n frame = _pll_tbl_sys._getframe(1)\n while frame is not None:\n if _pll_is_students(frame.f_code.co_filename):\n return frame.f_globals.get("__pll_level__", _PLL_LEVEL_RAW)\n frame = frame.f_back\n return _PLL_LEVEL_RAW\n\n\n# -----------------------------------------------------------------------------\n# Naming things in messages\n# -----------------------------------------------------------------------------\n\ndef _pll_literal(value, limit=60):\n """A value written the way it would be written in a program.\n\n `["Feb"]`, not `[\'Feb\']`: a student reads the message next to their own\n source, and showing them something they did not type makes them look\n for a difference that is not there.\n """\n if isinstance(value, str):\n text = \'"%s"\' % value\n elif isinstance(value, (list, tuple)):\n text = "[%s]" % ", ".join(_pll_literal(v, limit) for v in value)\n elif value is None or isinstance(value, bool):\n text = repr(value)\n elif isinstance(value, (int, float)):\n text = _format_cell(value)\n else:\n text = repr(value)\n if len(text) > limit:\n return text[: limit - 4] + " ...]" if text.startswith("[") else text[: limit - 3] + "..."\n return text\n\n\ndef _pll_closest(name, candidates):\n """The candidate `name` was probably meant to be, and why.\n\n Returns `(candidate, note)`, where the note explains a match that\n differs only in case - which is the commonest of these and the one a\n student is least likely to spot by reading.\n """\n if not isinstance(name, str):\n return (None, "")\n candidates = [c for c in candidates if isinstance(c, str)]\n for candidate in candidates:\n if candidate.lower() == name.lower():\n return (candidate, " Column names are case-sensitive.")\n # Only one column starting with what was written: `hours` for\n # `hours-worked`, `drinks` for `drinks-sold`. Too far for a spelling\n # match, and still almost certainly what was meant.\n starting = [c for c in candidates if c.lower().startswith(name.lower()) and name]\n if len(starting) == 1:\n return (starting[0], "")\n return (_pll_closest_name(name, candidates), "")\n\n\ndef _pll_q(name):\n """A column name written as a student writes it: `"month"`.\n\n `%r` gives `\'month\'`, which is not what they typed; a message that\n quotes their code differently from how they wrote it sends them looking\n for a difference that is not there.\n """\n return _pll_literal(name) if isinstance(name, str) else repr(name)\n\n\ndef _pll_no_column(name, columns, what="the table"):\n """The one wording for a column that is not there."""\n suggestion, note = _pll_closest(name, columns)\n return "%s has no column %s (it has: %s).%s%s" % (\n what,\n _pll_q(name),\n ", ".join(columns),\n " Did you mean %s?" % _pll_q(suggestion) if suggestion is not None else "",\n note,\n )\n\n\ndef _pll_function_name(fn):\n """`` `below_1k` `` for a message about a function the student passed."""\n name = getattr(fn, "__name__", None)\n return "`%s`" % name if name and name != "<lambda>" else "the function given"\n\n\ndef _pll_check_function(fn, who, takes):\n """A function argument, checked before it is used.\n\n Two mistakes hide here. Passing the *result* of a call rather than the\n function - `transform_column("servings", int())` - and passing a\n function that takes the wrong number of things, which fails inside the\n library with a message pointing at the `def` rather than at the call.\n """\n if not callable(fn):\n raise TypeError(\n "%s needs a function, but got %s. %s calls your function with "\n "%s at a time, so give it the function\'s name with no brackets "\n "after it - `int`, not `int()`." % (who, _pll_describe(fn), who, takes)\n )\n count = _pll_parameter_count(fn)\n if count is None or count == 1:\n return fn\n named = _pll_function_name(fn)\n if count == 0:\n raise TypeError(\n "%s calls %s with %s, but %s takes no parameters."\n % (who, named, takes, named)\n )\n raise TypeError(\n "%s calls %s with %s, but %s takes %d parameters."\n % (who, named, takes, named, count)\n )\n\n\ndef _pll_parameter_count(fn):\n """How many arguments `fn` must be called with, or None if unknowable.\n\n Read off the code object rather than with `inspect`, which is slower\n and would be imported only for this. Anything that is not a plain\n Python function - a builtin like `int`, a class, a partial - gives\n None, and is left to Python to check.\n """\n code = getattr(fn, "__code__", None)\n if code is None:\n return None\n if code.co_flags & 0x04: # *args: takes any number\n return None\n required = code.co_argcount\n defaults = getattr(fn, "__defaults__", None)\n if defaults:\n required -= len(defaults)\n if getattr(fn, "__self__", None) is not None:\n required -= 1\n return required\n\n\ndef _pll_raised_in_students_code(exc):\n """Whether `exc` came from inside the student\'s own code."""\n tb = exc.__traceback__\n while tb is not None:\n if _pll_is_students(tb.tb_frame.f_code.co_filename):\n return True\n tb = tb.tb_next\n return False\n\n\ndef _pll_apply_to_cell(fn, value, index, name):\n """`fn(value)`, with the row and value named if a conversion fails.\n\n `transform_column("tickets", int)` on a column with one blank cell\n fails with "invalid literal for int() with base 10: \'\'", which says\n nothing about which row or which column. Only a conversion - `int`,\n `float`, a function that is not the student\'s - is reworded: an error\n in their own function is theirs, and goes on as it was raised, with\n its own line and name, to be explained like any other.\n """\n try:\n return fn(value)\n except Exception as exc:\n if (\n type(exc) not in (ValueError, TypeError)\n or type(exc).__name__ == "TypeCheckError"\n or _pll_raised_in_students_code(exc)\n ):\n raise\n # What to do about it, rather than Python\'s own words after a colon:\n # a blank cell needs a decision, which is a function of their own.\n fn_name = getattr(fn, "__name__", "the function")\n if value == "":\n advice = (\n "A blank cell cannot be converted; write a function that "\n "decides what a blank should become, and give that to "\n "transform_column instead."\n )\n else:\n advice = "%s cannot convert %s." % (\n "`%s`" % fn_name if fn_name != "the function" else "The function",\n _pll_literal(value),\n )\n raise type(exc)(\n "transform_column(%s, %s) failed on the %s row, whose value is %s. %s"\n % (\n _pll_q(name),\n fn_name,\n _pll_ordinal(index),\n \'blank ("")\' if value == "" else _pll_literal(value),\n advice,\n )\n ) from None\n\n\nclass Row(dict):\n """One row of a table: a dict that can explain itself.\n\n A plain dict answers `r["rider"]` with `KeyError: \'rider\'` - a bare\n name, with nothing about the table it came from or the columns it does\n have - and `r.riders` with "\'dict\' object has no attribute \'riders\'".\n Both now say what the table methods say.\n\n It stays a `dict` in every other way, so a row still compares equal to\n the plain dict a test is written with, and everything that works on a\n dict still works.\n """\n\n __slots__ = ()\n\n def __missing__(self, key):\n raise KeyError(_pll_no_column(key, list(self), "this row"))\n\n def __getattr__(self, name):\n # Dunder and private lookups are Python\'s own probing (`copy`,\n # `__deepcopy__`, Pyodide\'s conversion); they have to keep failing\n # the ordinary way or those mechanisms break.\n if name.startswith("_"):\n raise AttributeError(name)\n if name in self:\n raise AttributeError(\n \'to get a value out of a row, use square brackets: row["%s"]\' % name\n )\n raise AttributeError(\n "%s Values come out of a row with square brackets, like row[%s]."\n % (\n _pll_no_column(name, list(self), "this row"),\n _pll_q(list(self)[0] if self else "column"),\n )\n )\n\n\n# -----------------------------------------------------------------------------\n# The Table class\n# -----------------------------------------------------------------------------\n\nclass Table:\n """A table: named columns, and rows of values in them.\n\n Make one with `table(columns, rows)`, `table_from_columns(...)` or\n `load_table("file.csv")`. A table never changes: every method that\n changes something gives back a new table.\n """\n\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 # A single string is iterable, so `table("month, riders", ...)`\n # would come apart into letters and be reported as duplicate columns.\n if isinstance(columns, str):\n raise TypeError(\n "table\'s column names should be a list of strings, like "\n \'["month", "riders"] - not one string.\'\n )\n cols = list(columns)\n for c in cols:\n if not isinstance(c, str):\n raise TypeError(\n "table\'s column names have to be strings, but one of them "\n "is %s." % _pll_describe(c)\n )\n if len(cols) != len(set(cols)):\n repeated = sorted({c for c in cols if cols.count(c) > 1})\n raise ValueError(\n "table has two columns called %s. Column names have to be "\n "different, so give one of them another name."\n % ", ".join(_pll_q(c) for c in repeated)\n )\n\n if isinstance(rows, str):\n raise TypeError(\n "table\'s rows should be a list of rows, like "\n \'[["Jan", 1], ["Feb", 2]] - not one string.\'\n )\n data = {c: [] for c in cols}\n n = 0\n for row in rows:\n for c, v in zip(cols, _pll_row_values(row, cols, n, "table")):\n data[c].append(v)\n n += 1\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 # Checked before the comparison below, which otherwise fails inside\n # `row` with "\'<\' not supported between instances of \'str\' and\n # \'int\'" - naming neither `row` nor the argument.\n if isinstance(index, float) and index.is_integer():\n raise TypeError(\n "row expects a whole row number, but got %r, which is a float: "\n "write row(%d)." % (index, int(index))\n )\n if not isinstance(index, _pll_tbl_numbers.Integral) or isinstance(index, bool):\n raise TypeError(\n "row expects a row number, but got %s. To find rows by a "\n "value, use filter." % _pll_describe(index)\n )\n index = int(index)\n if index < 0 or index >= self._length:\n raise IndexError(\n "there is no row %d: this table\'s rows are numbered 0 to %d."\n % (index, self._length - 1)\n if self._length > 0\n else "there is no row %d: this table has no rows." % index\n )\n return Row((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 _pll_check_function(predicate, "filter", "one row")\n keep = []\n for i in range(self._length):\n row = Row((c, self._data[c][i]) for c in self._columns)\n verdict = predicate(row)\n if not isinstance(verdict, bool):\n # A number is truthy, so a predicate that returns the value\n # it meant to compare would keep every row in silence.\n raise TypeError(\n "the function given to filter has to return True or "\n "False, but %s returned %s for the %s row. Did you mean "\n "to compare it with something?"\n % (\n _pll_function_name(predicate),\n _pll_describe(verdict),\n _pll_ordinal(i),\n )\n )\n if verdict:\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 _pll_check_function(fn, "transform_column", "one value")\n new_data = {c: list(self._data[c]) for c in self._columns}\n new_data[name] = [\n _pll_apply_to_cell(fn, v, i, name) for i, v in enumerate(self._data[name])\n ]\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 _pll_check_column_name(name, "add_column")\n if name in self._data:\n raise ValueError(\n "this table already has a column called %s. To change the "\n "values in it, use transform_column(%s, ...)."\n % (_pll_q(name), _pll_q(name))\n )\n if callable(values_or_fn):\n _pll_check_function(values_or_fn, "add_column", "one row")\n new_values = []\n for i in range(self._length):\n row = Row((c, self._data[c][i]) for c in self._columns)\n new_values.append(values_or_fn(row))\n else:\n if isinstance(values_or_fn, (str, dict)) or not hasattr(values_or_fn, "__iter__"):\n raise TypeError(\n "add_column needs a list of values, one for each row, or a "\n "function that works out the value from a row - but it was "\n "given %s." % _pll_describe(values_or_fn)\n )\n new_values = list(values_or_fn)\n if len(new_values) != self._length:\n raise ValueError(\n "add_column was given %d value%s for %s, but the table "\n "has %d rows."\n % (\n len(new_values),\n "" if len(new_values) == 1 else "s",\n _pll_q(name),\n self._length,\n )\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 add_row(self, row):\n """This table with `row` added at the end.\n\n `row` is a list of values in the order of the columns, or a dict\n from column names to values, as in `table(...)`.\n """\n values = _pll_row_values(row, self._columns, self._length, "add_row")\n new_data = {c: self._data[c] + [v] for c, v in zip(self._columns, values)}\n t = Table.__new__(Table)\n t._columns = list(self._columns)\n t._data = new_data\n t._length = self._length + 1\n return t\n\n def select_columns(self, names):\n """Keep only the columns in `names`, in that order."""\n # One string is iterable, so `select_columns("name")` would come\n # apart into letters and complain about a column called \'n\'.\n if isinstance(names, str):\n raise TypeError(\n "select_columns takes a list of column names, like [%s] - "\n "not one name on its own." % _pll_q(names)\n )\n names = list(names)\n for n in names:\n self._require_column(n)\n repeated = sorted({n for n in names if names.count(n) > 1})\n if repeated:\n raise ValueError(\n "select_columns was given %s more than once; a table cannot "\n "have two columns of one name." % ", ".join(_pll_q(n) for n in repeated)\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 # `ascending="False"` is a non-empty string, so it would sort\n # ascending without a word.\n if not isinstance(ascending, bool):\n raise TypeError(\n "order_by\'s ascending has to be True or False, but it is %s."\n % _pll_describe(ascending)\n )\n values = self._data[name]\n self._refuse_numbers_as_text(name, "order_by", "sorts")\n # Sort indices to keep all columns in lockstep.\n order = sorted(\n range(self._length),\n key=lambda i: _sort_key(values[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 n = _pll_row_count(n, "head")\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 n = _pll_row_count(n, "tail")\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(self._numeric_column(name, "sum", counting=True))\n\n def mean(self, name):\n """Mean (average) of `name`."""\n values = self._numeric_column(name, "mean", counting=True)\n if len(values) == 0:\n raise ValueError(self._empty("mean", "nothing to average"))\n return sum(values) / len(values)\n\n def min(self, name):\n """Minimum value in `name`."""\n self._require_column(name)\n if self._length == 0:\n raise ValueError(self._empty("min", "no smallest value"))\n self._refuse_numbers_as_text(name, "min", "compares")\n return min(self._data[name], key=_sort_key)\n\n def max(self, name):\n """Maximum value in `name`."""\n self._require_column(name)\n if self._length == 0:\n raise ValueError(self._empty("max", "no largest value"))\n self._refuse_numbers_as_text(name, "max", "compares")\n return max(self._data[name], 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", swap_with=x)\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 return self._scatter(x, y, title, "scatter_chart")\n\n def _scatter(self, x, y, title, who):\n xs = self._numeric_column(x, who)\n ys = self._numeric_column(y, who)\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"): the buckets\n then start at a multiple of the width.\n """\n values = self._numeric_column(name, "histogram")\n if bin_width is not None:\n if not _pll_is_number(bin_width) or not _math.isfinite(bin_width):\n raise TypeError(\n "histogram\'s `bin_width` has to be a number, but it is %s."\n % _pll_describe(bin_width)\n )\n if bin_width <= 0:\n raise ValueError(\n "histogram\'s `bin_width` has to be more than 0, but it is %s."\n % _pll_number(bin_width)\n )\n if values and (max(values) - min(values)) / bin_width > 1000:\n raise ValueError(\n "histogram\'s `bin_width` of %s would make more than 1000 bars "\n "across these values; use a wider one."\n % _pll_number(bin_width)\n )\n return _PllChart(_render_histogram(values, None, name, title, bin_width))\n if isinstance(bins, bool) or not isinstance(bins, _pll_tbl_numbers.Integral):\n raise TypeError(\n "histogram\'s `bins` has to be a whole number - it is how many bars "\n "to draw - but it is %s." % _pll_describe(bins)\n )\n if bins < 1:\n raise ValueError(\n "histogram\'s `bins` has to be 1 or more - it is how many bars "\n "to draw - but it is %s." % _pll_number(bins)\n )\n return _PllChart(_render_histogram(values, int(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(x, y, title, "scatter_plot")\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 index, amount in enumerate(amounts):\n if amount < 0:\n raise ValueError(\n "a pie chart cannot show a negative value, but column %s "\n "holds %s in the %s row."\n % (_pll_q(values), _pll_number(amount), _pll_ordinal(index))\n )\n names = [_format_cell(v) for v in self._data[labels]]\n return _PllChart(_render_pie_chart(names, amounts, title, values))\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(self._empty("dot_plot", "nothing to plot"))\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(self._empty("labeled_dot_plot", "nothing to plot"))\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. The bars are in the values\' order:\n numbers by size, words alphabetically.\n """\n self._require_column(name)\n counts = {}\n for value in self._data[name]:\n key = _format_cell(value)\n if key not in counts:\n counts[key] = [value, 0]\n counts[key][1] += 1\n if not counts:\n raise ValueError(self._empty("freq_bar_chart", "nothing to count"))\n keys = sorted(counts, key=lambda k: _sort_key(counts[k][0]))\n return _PllChart(\n _render_bar_chart(keys, [float(counts[k][1]) 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(self._empty("box_plot", "nothing to plot"))\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 `rows` are the cells shown, as text; `numeric` says which columns\n hold only numbers, which the panel lines up on the right - so text\n that looks like a number, from a CSV, is left-aligned and can be\n seen not to be one. `csv` is the whole table for Save CSV, every\n row and every digit, as far as `_PLL_CSV_LIMIT`.\n """\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 numeric = []\n for c in self._columns:\n cells = [v for v in self._data[c][:shown] if v is not None]\n numeric.append(bool(cells) and all(_pll_is_number(v) for v in cells))\n csv_text, csv_rows = self._csv_text(_PLL_CSV_LIMIT)\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 "numeric": numeric,\n "csv": csv_text,\n "csv_rows": csv_rows,\n }\n\n def _csv_text(self, limit):\n """The table as CSV, and how many rows that is: all that fit in `limit`."""\n out = _io.StringIO()\n writer = _csv.writer(out, lineterminator="\\n")\n writer.writerow(self._columns)\n written = 0\n for i in range(self._length):\n writer.writerow([_format_cell(self._data[c][i]) for c in self._columns])\n if out.tell() > limit:\n break\n written += 1\n text = out.getvalue()\n if written < self._length:\n # The row that went past the limit is not kept.\n text = text[: text.rstrip("\\n").rfind("\\n") + 1]\n return text, written\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` - but\n a CSV\'s cells are text, and its "29" is not the number 29 until the\n column is converted.\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 # ---- Used the way a list or a dict is ----\n\n def __getitem__(self, key):\n if isinstance(key, str):\n raise TypeError(\n "a table\'s columns are not taken out with square brackets: "\n "write t.column(%s) for the values in it." % _pll_q(key)\n )\n if isinstance(key, _pll_tbl_numbers.Integral) and not isinstance(key, bool):\n raise TypeError(\n "a table\'s rows are not taken out with square brackets: "\n "write t.row(%d) for that row." % key\n )\n raise TypeError(\n "a table is not taken apart with square brackets: t.row(0) is a "\n \'row, t.column("name") a column, and t.rows() every row.\'\n )\n\n def __iter__(self):\n raise TypeError(\n "a table is not a list of rows itself: loop over t.rows(), as in "\n "`for r in t.rows():`."\n )\n\n def __getattr__(self, name):\n # Only for names the class does not have. Private and special\n # lookups are Python\'s own probing, and fail the ordinary way.\n if name.startswith("_"):\n raise AttributeError(name)\n if name in self._data:\n # `name=None`, so Python adds no "Did you mean" of its own: the\n # column is the answer, not a method spelt like it.\n raise AttributeError(\n "a table\'s columns are not attributes: write t.column(%s) for "\n "the values in it." % _pll_q(name),\n name=None,\n obj=None,\n )\n # Python adds the nearest method, after a full stop of its own.\n raise AttributeError("a table has no method `%s`" % name)\n\n # ---- Repr ----\n\n def __repr__(self):\n """A readable rendering, because this is what a failed test prints.\n\n The rows, not a summary like `<Table 3 rows x 2 columns: name, mpg>`,\n which is the same for any two tables of the same shape - 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 not isinstance(name, str):\n raise TypeError(\n "a column is named by a string, like %s, but this is %s.%s"\n % (\n _pll_q(self._columns[0]) if self._columns else \'"name"\',\n _pll_describe(name),\n " To keep several columns, use select_columns."\n if isinstance(name, (list, tuple))\n else "",\n )\n )\n if name not in self._data:\n raise KeyError(_pll_no_column(name, self._columns))\n\n def _empty(self, op, what):\n return "%s needs at least one row, but the table has none, so there is %s." % (op, what)\n\n def _refuse_numbers_as_text(self, name, op, does):\n """Stop `op` giving a wrong answer for numbers a CSV left as text.\n\n Text compares letter by letter, so "9" comes after "100". When every\n value in the column is a number written as text, and comparing them\n as text gives a different answer than as numbers would, the\n column was almost certainly never converted.\n """\n values = self._data[name]\n texts = [v for v in values if isinstance(v, str) and v.strip() != ""]\n if not texts or len(texts) != sum(1 for v in values if v is not None and v != ""):\n return\n try:\n numbers = [float(v) for v in texts]\n except ValueError:\n return\n if sorted(range(len(texts)), key=lambda i: texts[i]) == sorted(\n range(len(texts)), key=lambda i: (numbers[i], texts[i])\n ):\n return\n whole = all(n.is_integer() for n in numbers)\n first, second = sorted(texts, key=lambda t: float(t))[0], sorted(texts)[0]\n raise TypeError(\n "%s %s column %s as text, and as text %s comes before %s: the values "\n "were read from a CSV and never converted to numbers. Convert the "\n "column first: transform_column(%s, %s)."\n % (\n op,\n does,\n _pll_q(name),\n _pll_q(second),\n _pll_q(first),\n _pll_q(name),\n "int" if whole else "float",\n )\n )\n\n def _numeric_column(self, name, op, swap_with=None, counting=False):\n """The column as numbers, or an error saying why it is not.\n\n `swap_with` is the other column of a two-column chart, so a chart\n given its arguments the wrong way round can say so. `counting` is\n for `sum` and `mean`, which keep each number as it is - whole\n numbers add up to a whole number - and refuse True and False at the\n teaching levels, where neither is a number.\n """\n self._require_column(name)\n strict = counting and _pll_student_level() in _PLL_TEACHING_LEVELS\n out = []\n for index, v in enumerate(self._data[name]):\n if isinstance(v, bool):\n if strict:\n raise TypeError(\n "%s needs a column of numbers, but column %s holds %s in the "\n "%s row. True and False are not numbers; to count the rows "\n "where it is True, filter on it and take the length."\n % (op, _pll_q(name), v, _pll_ordinal(index))\n )\n out.append(int(v) if counting else float(v))\n continue\n if _pll_is_number(v):\n if isinstance(v, float) and not _math.isfinite(v):\n raise ValueError(\n "%s needs numbers it can %s, but column %s holds %s in the %s "\n "row. Leave such rows out first, with filter."\n % (\n op,\n "add up" if counting else "draw",\n _pll_q(name),\n "nan (\\"not a number\\")" if v != v else repr(v),\n _pll_ordinal(index),\n )\n )\n out.append(v if counting else float(v))\n continue\n raise TypeError(self._not_numeric(name, v, index, op, swap_with))\n return out\n\n def _looks_numeric(self, name):\n """Whether a column is numbers written as text, rather than words.\n\n What separates `"1123"`, which only needs converting, from `"Mon"`,\n which is simply not a number - and so decides whether advising\n `transform_column(..., float)` would help or mislead.\n """\n values = [v for v in self._data[name] if v is not None and v != ""]\n if not values:\n return False\n numeric = sum(1 for v in values if _pll_reads_as_number(v))\n return numeric * 2 >= len(values)\n\n def _not_numeric(self, name, value, index, op, swap_with):\n """Why `op` cannot use this column, and what would fix it."""\n base = (\n "%s needs a column of numbers, but column %s holds %s in the %s row"\n % (op, _pll_q(name), _pll_describe(value), _pll_ordinal(index))\n )\n # Arguments the wrong way round: the labels column was given where\n # the measured one belongs, and the other one is the numbers.\n if (\n swap_with is not None\n and not self._looks_numeric(name)\n and self._looks_numeric(swap_with)\n ):\n return "%s. %s takes the labels column first: %s(%s, %s)." % (\n base,\n op,\n op,\n _pll_q(name),\n _pll_q(swap_with),\n )\n if self._looks_numeric(name):\n # Numbers written as text, which is every column of a CSV.\n return (\n "%s. Values read from a CSV are text until they are "\n "converted: transform_column(%s, float) first."\n % (base, _pll_q(name))\n )\n return "%s. That column holds text, so there is nothing to measure." % base\n\n\ndef _pll_check_column_name(name, who):\n if not isinstance(name, str):\n raise TypeError(\n "%s\'s column name has to be a string, like \\"total\\", but it is %s."\n % (who, _pll_describe(name))\n )\n\n\ndef _pll_row_count(n, who):\n """How many rows `head` or `tail` is asked for: a whole number, not negative."""\n if isinstance(n, bool) or not isinstance(n, _pll_tbl_numbers.Integral):\n raise TypeError(\n "%s takes how many rows to keep, as a whole number like %s(5), but it "\n "was given %s." % (who, who, _pll_describe(n))\n )\n if n < 0:\n raise ValueError(\n "%s takes how many rows to keep, which cannot be negative, but it is %d."\n % (who, n)\n )\n return int(n)\n\n\ndef _pll_row_values(row, cols, index, who):\n """One row\'s values, in the order of `cols`, checked."""\n if isinstance(row, dict):\n unknown = [key for key in row if key not in cols]\n if unknown:\n suggestion, note = _pll_closest(unknown[0], cols)\n raise ValueError(\n "the %s row has %s, which is not one of the columns (%s).%s%s"\n % (\n _pll_ordinal(index),\n _pll_q(unknown[0]),\n ", ".join(cols),\n " Did you mean %s?" % _pll_q(suggestion) if suggestion else "",\n note,\n )\n )\n return [row.get(c) for c in cols]\n if isinstance(row, str) or not hasattr(row, "__iter__"):\n # `table(columns, ["Jan", 1])` - the values of one row where a list\n # of rows belongs. Iterating it would take a string apart into\n # letters and blame the wrong thing.\n if who == "add_row":\n raise TypeError(\n "add_row takes one row, as a list of values or a dict, but it was "\n "given %s." % _pll_describe(row)\n )\n raise TypeError(\n "each row should be a list of values, but the %s row is "\n "%s. Put every row inside one outer list: "\n "table(columns, [[...], [...]])."\n % (_pll_ordinal(index), _pll_describe(row))\n )\n values = list(row)\n if len(values) != len(cols):\n raise ValueError(\n "the %s row, %s, has %d value%s, but the table has %d "\n "columns: %s"\n % (\n _pll_ordinal(index),\n _pll_literal(values),\n len(values),\n "" if len(values) == 1 else "s",\n len(cols),\n ", ".join(cols),\n )\n )\n return values\n\n\ndef _pll_table_differences(left, right):\n """Where two tables differ, for a failed `assert left == right`, or None.\n\n A table\'s repr shows its first rows, which are the same in two tables\n that differ further down.\n """\n if not (isinstance(left, Table) and isinstance(right, Table)):\n return None\n if left._columns != right._columns:\n return [\n "The columns differ: %s and %s."\n % (_pll_literal(left._columns, 200), _pll_literal(right._columns, 200))\n ]\n lines = []\n if left._length != right._length:\n lines.append("The first has %d rows and the second %d." % (left._length, right._length))\n differ = [\n i\n for i in range(min(left._length, right._length))\n if any(left._data[c][i] != right._data[c][i] for c in left._columns)\n ]\n if differ:\n i = differ[0]\n lines.append(\n "Row %d is the first that differs: %r and %r%s."\n % (\n i,\n [left._data[c][i] for c in left._columns],\n [right._data[c][i] for c in left._columns],\n " (%d more rows differ)" % (len(differ) - 1) if len(differ) > 1 else "",\n )\n )\n return lines\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 if not isinstance(data, dict):\n raise TypeError(\n "table_from_columns takes one dictionary, from each column\'s name to "\n \'its values: table_from_columns({"month": ["Jan", "Feb"], "riders": [1, 2]}) - \'\n "but it was given %s." % _pll_describe(data)\n )\n cols = list(data.keys())\n for c in cols:\n _pll_check_column_name(c, "table_from_columns")\n values = data[c]\n # One string is iterable, and would become a column of its letters.\n if isinstance(values, (str, dict)) or not hasattr(values, "__iter__"):\n raise TypeError(\n "table_from_columns needs a list of values for each column, but "\n "%s is %s." % (_pll_q(c), _pll_describe(values))\n )\n columns = {c: list(data[c]) for c in cols}\n lengths = {c: len(columns[c]) for c in cols}\n if len(set(lengths.values())) > 1:\n first, other = cols[0], next(c for c in cols if lengths[c] != lengths[cols[0]])\n raise ValueError(\n "table_from_columns needs every column to be the same length, but "\n "%s has %d value%s and %s has %d."\n % (\n _pll_q(first),\n lengths[first],\n "" if lengths[first] == 1 else "s",\n _pll_q(other),\n lengths[other],\n )\n )\n return Table._from_columns(cols, columns, lengths[cols[0]] if cols else 0)\n\n\ndef _pll_refuse_html(text, source):\n """Stop early when the address gave a web page rather than a CSV.\n\n A GitHub file\'s own address serves the page you look at in a browser,\n not the file, and the page parsed as CSV produced "Line 15 of \'...\' has\n 9 value(s) but there are 1 columns (<!DOCTYPE html>)" - a message about\n a line nobody wrote.\n """\n start = text.lstrip("\\ufeff \\t\\r\\n")\n # Past any comments: a page may start with one.\n while start.startswith("<!--"):\n end = start.find("-->")\n if end < 0:\n break\n start = start[end + 3:].lstrip()\n start = start[:200].lower()\n if not (start.startswith("<!doctype html") or start.startswith("<html")):\n return\n hint = ""\n if "github.com" in str(source):\n hint = (\n " On GitHub, open the file and use the address behind the Raw "\n "button (raw.githubusercontent.com)."\n )\n raise ValueError(\n \'"%s" gave back a web page, not a CSV file.%s\' % (source, hint)\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 _pll_refuse_html(text, source)\n records = _pll_csv_records(text, source)\n\n def blank(row):\n return not any(cell.strip() for cell in row)\n\n # Blank lines before the names and after the last row are not rows; an\n # editor or a spreadsheet leaves them there.\n while records and blank(records[0][1]):\n records.pop(0)\n while records and blank(records[-1][1]):\n records.pop()\n if not records:\n raise ValueError("%r has no rows in it." % source)\n\n header = [name.strip() for name in records[0][1]]\n if len(header) != len(set(header)):\n seen = set()\n for name in header:\n if name in seen:\n raise ValueError(\n "%s has two columns called %s. Column names have to be "\n "different, so give one of them another name."\n % (_pll_q(source), _pll_q(name))\n )\n seen.add(name)\n blank_names = [i for i, name in enumerate(header) if not name]\n if blank_names:\n raise ValueError(\n "Column %d of %r has no name in the first row." % (blank_names[0] + 1, source)\n )\n\n raw = {name: [] for name in header}\n length = 0\n for line, row in records[1:]:\n if not row or (len(row) == 1 and not row[0].strip()):\n # An empty line: in a file of one column, an empty cell, and in\n # any other just a gap between rows.\n if len(header) != 1:\n continue\n row = [""]\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, source, len(row), len(header), ", ".join(header))\n )\n for name, cell in zip(header, row):\n raw[name].append(cell)\n length += 1\n\n return Table._from_columns(header, raw, length)\n\n\ndef _pll_csv_records(text, source):\n """`(line, cells)` for each record of a CSV, with the line it starts on.\n\n `csv` rather than `split(",")`: quoted fields containing commas and\n newlines are ordinary in real data, and getting them wrong shifts every\n later column without saying anything. Strict, so a quote never closed\n is an error rather than the rest of the file in one cell.\n """\n reader = _csv.reader(_io.StringIO(text), strict=True)\n records = []\n line = 1\n try:\n for row in reader:\n records.append((line, row))\n line = reader.line_num + 1\n except _csv.Error as e:\n if "unexpected end of data" in str(e):\n raise ValueError(\n \'%s has a quote (") on line %d that is never closed, so everything \'\n "after it would be one cell. Close the quote, or take it out."\n % (_pll_q(source), line)\n ) from None\n raise ValueError(\n "Could not read %s as a CSV: line %d: %s" % (_pll_q(source), line, e)\n ) from None\n return records\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 Where `f` has no value - 1/x at 0, a square root below 0 - the line\n has a gap.\n """\n _pll_check_function(f, "function_plot", "one number")\n for name, value in (("x_min", x_min), ("x_max", x_max)):\n if not _pll_is_number(value) or not _math.isfinite(value):\n raise TypeError(\n "function_plot\'s `%s` has to be a number, but it is %s."\n % (name, _pll_describe(value))\n )\n if x_max <= x_min:\n raise ValueError("function_plot needs x_max to be greater than x_min")\n if isinstance(steps, bool) or not isinstance(steps, _pll_tbl_numbers.Integral) or steps < 2:\n raise ValueError(\n "function_plot\'s `steps` has to be a whole number, at least 2, but it is %s."\n % _pll_describe(steps)\n )\n xs = []\n ys = []\n starts = []\n gap = True\n failed = None\n for i in range(steps + 1):\n x = x_min + (x_max - x_min) * i / steps\n try:\n value = _to_number(f(x))\n except (ZeroDivisionError, ValueError, OverflowError) as e:\n # No value here: math\'s own errors, for 1/x at 0 or log of 0.\n failed = failed or e\n value = None\n if value is None:\n gap = True\n continue\n if gap:\n starts.append(len(xs))\n gap = False\n xs.append(x)\n ys.append(value)\n if not xs and failed is not None:\n # Nowhere at all: that is not a gap but the function failing.\n raise failed\n if len(xs) < 2:\n raise ValueError(\n "function_plot found no numbers to draw between x=%s and x=%s"\n % (_pll_number(x_min), _pll_number(x_max))\n )\n return _PllChart(\n _render_xy_chart(xs, ys, "x", "y", title, mode="line", markers=False, starts=starts)\n )\n\n\n# -----------------------------------------------------------------------------\n# Numeric helpers\n# -----------------------------------------------------------------------------\n\ndef _to_number(value):\n if isinstance(value, bool):\n return float(value)\n if _pll_is_number(value):\n try:\n number = float(value)\n except OverflowError:\n return None\n return number if _math.isfinite(number) else None\n return None\n\n\ndef _pll_reads_as_number(value):\n """Whether this value *could* be a number.\n\n Wider than `_to_number`, which refuses a string on purpose: this is\n only for deciding whether to suggest a conversion, and `"29"` from a\n CSV is exactly the case where suggesting one is right.\n """\n if _to_number(value) is not None:\n return True\n if not isinstance(value, str):\n return False\n try:\n float(value.strip())\n except ValueError:\n return False\n return True\n\n\ndef _sum_numeric(values):\n return sum(values)\n\n\ndef _sort_key(value):\n """Sort key tolerant of mixed None/str/numeric columns.\n\n NaN after every number: it compares as neither larger nor smaller, so\n among numbers it would leave the order as it found it.\n """\n if value is None:\n return (0, 0)\n if isinstance(value, bool):\n return (1, int(value))\n if _pll_is_number(value):\n if value != value:\n return (1.5, 0)\n return (1, 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, made into a `_PllChart` - an `Image` from the image\n# library, loaded before this one into the same globals - so a chart is\n# displayed, `beside`d and annotated `-> Image` like any picture.\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\nclass _PllChart(_Drawing):\n """A chart: an image, which displays and combines like any other."""\n\n def __init__(self, svg_payload):\n # svg_payload: {"width", "height", "data"}\n _Drawing.__init__(self, svg_payload["data"], svg_payload["width"], svg_payload["height"])\n\n def __repr__(self):\n return "<Chart %dx%d>" % (_pll_px(self._w), _pll_px(self._h))\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, _pll_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, _pll_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, _pll_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, _pll_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, _pll_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, _pll_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, _pll_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, _pll_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, starts=(0,)):\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. `starts` are the points a line starts afresh\n at, after a gap.\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 lifted = set(starts)\n path = " ".join(\n "%s %g %g" % ("M" if i in lifted or i == 0 else "L", sx, sy)\n for i, (sx, sy) in enumerate(coords)\n )\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, column):\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 in column %s to add up to more than "\n "zero, but they add up to %s." % (_pll_q(column), _pll_number(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, _pll_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, bin_width=None):\n """Counts per bucket: `bins` of them across the values, or buckets\n `bin_width` wide starting at a multiple of it, so ages grouped by 5\n are 20-25, 25-30, ...\n """\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 bin_width is not None:\n width = float(bin_width)\n lo = _math.floor(lo / width) * width\n bins = int((hi - lo) // width) + 1\n hi = lo + bins * width\n else:\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 = min(int((v - lo) / width), 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 install step.\n# -----------------------------------------------------------------------------\n\nPLL_TABLE_EXPORTS = [\n "Table",\n "Row",\n "table",\n "table_from_columns",\n "load_table",\n "function_plot",\n]\n\n# Each function says it is `pll.table`\'s, so `help(load_table)` does.\nfor _pll_exported in PLL_TABLE_EXPORTS:\n _pll_value = globals()[_pll_exported]\n if callable(_pll_value) and not isinstance(_pll_value, type):\n _pll_value.__module__ = "pll.table"\ndel _pll_exported, _pll_value\n';
946
225
 
947
226
  // src/common/reactorLib.py
948
- var reactorLib_default = '# Python Language Levels: interactive reactors (big-bang / animate).\n#\n# Loaded into Pyodide after imageLib, so `to_draw` handlers can use the\n# image primitives without importing anything.\n#\n# A reactor is a *value*: it holds the handlers plus the current state, and\n# `react` returns a new reactor rather than mutating one. That is what makes\n# stepping backwards possible - the trace is simply the states we have seen,\n# and rewinding is re-drawing one of them.\n#\n# Nothing in here runs an event loop. The extension host owns the clock (and\n# the socket, for the universe client) and calls `_pll_reactor_step` once per\n# event. A loop in here would hold the worker and the whole exec chain for as\n# long as the animation ran, which is the failure mode `Stop` exists for.\n\nimport json as _rx_json\n\n_PLL_DEFAULT_TICK_RATE = 1.0 / 28.0\n\n# Longest trace we keep. A reactor left running for an hour at 28 ticks a\n# second would otherwise hold 100k states alive just so it could rewind.\n_PLL_MAX_TRACE = 10000\n\n_PLL_HANDLER_NAMES = (\n "init",\n "to_draw",\n "on_tick",\n "tick_rate",\n "stop_when",\n "on_key",\n "on_mouse",\n "on_receive",\n "register",\n "title",\n)\n\n\nclass _PllPackage:\n """A new state, plus a message to send to the server."""\n\n __slots__ = ("state", "message")\n\n def __init__(self, state, message):\n self.state = state\n self.message = message\n\n def __repr__(self):\n return "package(%r, %r)" % (self.state, self.message)\n\n\ndef package(state, message):\n """Return `state` to the reactor and send `message` to the server."""\n return _PllPackage(state, message)\n\n\ndef _pll_split_package(result):\n """`(state, messages)` for whatever a handler returned."""\n if isinstance(result, _PllPackage):\n return result.state, (result.message,)\n return result, ()\n\n\nclass Reactor:\n """An interactive program: handlers plus the state they act on.\n\n Build one with `reactor(...)`. Every method that advances it returns a\n *new* Reactor, so a reactor you are holding never changes underneath\n you - `r.react(...)` twice from the same `r` gives the same answer.\n """\n\n __slots__ = ("_h", "_state", "_trace", "_tracing", "_outbox", "_shown")\n\n def __init__(self, handlers, state, trace=None, tracing=False, outbox=()):\n self._h = handlers\n self._state = state\n self._trace = list(trace) if trace else []\n self._tracing = tracing\n self._outbox = tuple(outbox)\n # Set by `interact()` on the reactor it hands back, so a top-level\n # `big_bang(...)` shows its card and not also its repr.\n self._shown = False\n\n @property\n def _pll_already_displayed(self):\n return self._shown\n\n def _pll_image_data(self):\n """Display protocol: a reactor shows the picture for its state.\n\n So `r` at the prompt draws the current frame, the same way a bare\n image or table displays itself.\n """\n return self.draw()._pll_image_data()\n\n # -- plumbing -----------------------------------------------------\n\n def _next(self, state, messages=()):\n trace = self._trace\n if self._tracing and len(trace) < _PLL_MAX_TRACE:\n trace = trace + [state]\n return Reactor(self._h, state, trace, self._tracing, messages)\n\n def _handler(self, name):\n return self._h.get(name)\n\n # -- inspection ---------------------------------------------------\n\n def get_value(self):\n """The current state."""\n return self._state\n\n def draw(self):\n """The image for the current state."""\n return self._h["to_draw"](self._state)\n\n def is_stopped(self):\n """True when `stop_when` says this state is the last one."""\n stop = self._handler("stop_when")\n return bool(stop(self._state)) if stop else False\n\n @property\n def title(self):\n return self._h.get("title") or "reactor"\n\n @property\n def tick_rate(self):\n return self._h.get("tick_rate", _PLL_DEFAULT_TICK_RATE)\n\n @property\n def register(self):\n return self._h.get("register")\n\n def handles(self, name):\n """Whether this reactor has the named handler."""\n return self._handler(name) is not None\n\n def outgoing(self):\n """Messages the last `react` asked to send to the server."""\n return list(self._outbox)\n\n # -- advancing ----------------------------------------------------\n\n def react(self, event):\n """A new reactor with one event applied.\n\n `event` is a dict: `{"kind": "tick"}`, `{"kind": "key", "key": "left"}`,\n `{"kind": "mouse", "x": 1, "y": 2, "event": "button-down"}`, or\n `{"kind": "receive", "message": ...}`. An event this reactor has no\n handler for leaves the state alone.\n """\n kind = event.get("kind") if isinstance(event, dict) else None\n if kind == "tick":\n handler = self._handler("on_tick")\n args = (self._state,)\n elif kind == "key":\n handler = self._handler("on_key")\n args = (self._state, event.get("key", ""))\n elif kind == "mouse":\n handler = self._handler("on_mouse")\n args = (\n self._state,\n event.get("x", 0),\n event.get("y", 0),\n event.get("event", "move"),\n )\n elif kind == "receive":\n handler = self._handler("on_receive")\n args = (self._state, event.get("message"))\n else:\n raise ValueError("react: unknown event kind %r" % (kind,))\n if handler is None:\n return self\n state, messages = _pll_split_package(handler(*args))\n return self._next(state, messages)\n\n def tick(self):\n """Shorthand for `react({"kind": "tick"})`."""\n return self.react({"kind": "tick"})\n\n # -- tracing ------------------------------------------------------\n\n def start_trace(self):\n """A new reactor that records every state it passes through."""\n return Reactor(self._h, self._state, [self._state], True, self._outbox)\n\n def stop_trace(self):\n """A new reactor that stops recording, keeping what it has."""\n return Reactor(self._h, self._state, self._trace, False, self._outbox)\n\n def get_trace(self):\n """The states recorded so far, oldest first."""\n return list(self._trace)\n\n def simulate_trace(self, limit):\n """Run up to `limit` ticks with tracing on, stopping at `stop_when`.\n\n No drawing and no clock: this is the way to test a reactor\'s logic\n at the prompt, or in a test, without watching it.\n """\n if limit < 0:\n raise ValueError("simulate_trace needs a limit of at least 0")\n current = self.start_trace()\n for _ in range(limit):\n if current.is_stopped():\n break\n nxt = current.tick()\n if nxt is current:\n break # no on_tick handler; nothing would ever change\n current = nxt\n return current\n\n def interact(self):\n """Show this reactor in the interactions panel and start it."""\n return _pll_reactor_interact(self)\n\n def __repr__(self):\n return "<reactor %s state=%r>" % (self.title, self._state)\n\n\ndef reactor(**handlers):\n """Build a reactor. `init` and `to_draw` are required.\n\n Handlers: `on_tick(state)`, `on_key(state, key)`,\n `on_mouse(state, x, y, event)`, `on_receive(state, message)`,\n `stop_when(state)`, plus `tick_rate` (seconds), `title`, and `register`\n (a `ws://` URL for the universe client).\n """\n unknown = [k for k in handlers if k not in _PLL_HANDLER_NAMES]\n if unknown:\n raise ValueError(\n "reactor: unknown handler%s %s. Known: %s"\n % (\n "" if len(unknown) == 1 else "s",\n ", ".join(repr(u) for u in sorted(unknown)),\n ", ".join(_PLL_HANDLER_NAMES),\n )\n )\n if "init" not in handlers:\n raise ValueError("reactor: needs an `init` state")\n if "to_draw" not in handlers:\n raise ValueError("reactor: needs a `to_draw` handler")\n for name in ("to_draw", "on_tick", "stop_when", "on_key", "on_mouse", "on_receive"):\n fn = handlers.get(name)\n if fn is not None and not callable(fn):\n raise ValueError("reactor: `%s` must be a function" % name)\n rate = handlers.get("tick_rate", _PLL_DEFAULT_TICK_RATE)\n if not isinstance(rate, (int, float)) or isinstance(rate, bool) or rate <= 0:\n raise ValueError("reactor: `tick_rate` must be a number of seconds above 0")\n state = handlers.pop("init")\n handlers["tick_rate"] = float(rate)\n return Reactor(handlers, state)\n\n\ndef big_bang(init, **handlers):\n """Build a reactor and start it. Racket\'s `big-bang`."""\n handlers["init"] = init\n return reactor(**handlers).interact()\n\n\ndef animate(to_draw, **handlers):\n """Animate `to_draw(n)`, where `n` counts ticks from 0. Racket\'s `animate`."""\n handlers.setdefault("init", 0)\n handlers.setdefault("on_tick", lambda n: n + 1)\n handlers["to_draw"] = to_draw\n return reactor(**handlers).interact()\n\n\nPLL_REACTOR_EXPORTS = [\n "Reactor",\n "reactor",\n "package",\n "big_bang",\n "animate",\n]\n\n\n# -----------------------------------------------------------------------------\n# Running reactors (driven by the extension host, one event per call)\n# -----------------------------------------------------------------------------\n\n_pll_reactors = {}\n_pll_reactor_seq = 0\n\n\nclass _PllRunning:\n """A reactor being interacted with, plus the frames it has been through.\n\n The history is a list of `(reactor, event)` pairs with a cursor, rather\n than a list of states, so rewinding and then playing forward again is\n *replay* - the same reactor values, not a recomputation that could drift\n if a handler is not deterministic. A new event at a rewound cursor\n discards the frames after it, exactly like an editor\'s undo history.\n """\n\n __slots__ = ("frames", "cursor", "dropped")\n\n def __init__(self, reactor_value):\n self.frames = [(reactor_value, None)]\n self.cursor = 0\n # Frames aged out of the front, so the card can still number them.\n self.dropped = 0\n\n @property\n def current(self):\n return self.frames[self.cursor][0]\n\n def seek(self, index):\n self.cursor = max(0, min(index, len(self.frames) - 1))\n return self.current\n\n def step(self, event):\n """Advance by one event. True if the frame was newly computed.\n\n The return value matters for the universe client: a *replayed* frame\n still carries the messages its handler produced the first time, and\n re-sending them because the student dragged the slider back and\n played forward would be wrong.\n """\n ahead = self.cursor + 1\n # Exact redo: the frame we already have came from this same event.\n if ahead < len(self.frames) and self.frames[ahead][1] == event:\n self.cursor = ahead\n return False\n del self.frames[ahead:]\n nxt = self.current.react(event)\n self.frames.append((nxt, event))\n self.cursor = len(self.frames) - 1\n if len(self.frames) > _PLL_MAX_TRACE:\n drop = len(self.frames) - _PLL_MAX_TRACE\n del self.frames[:drop]\n self.dropped += drop\n self.cursor -= drop\n return True\n\n\ndef _pll_reactor_frame(image):\n return {\n "data": image.to_svg(),\n "width": int(image.width) + (1 if image.width % 1 else 0),\n "height": int(image.height) + (1 if image.height % 1 else 0),\n }\n\n\ndef _pll_reactor_view(rid, running):\n """What the host needs to render the current frame."""\n current = running.current\n return {\n "ok": True,\n "id": rid,\n "frame": _pll_reactor_frame(current.draw()),\n "index": running.dropped + running.cursor,\n "length": running.dropped + len(running.frames),\n "at_end": running.cursor == len(running.frames) - 1,\n "stopped": current.is_stopped(),\n "value_repr": repr(current.get_value()),\n }\n\n\ndef _pll_reactor_failure(exc):\n return {\n "ok": False,\n "error_type": type(exc).__name__,\n "error_message": str(exc),\n "traceback": _pll_format_exception(exc),\n }\n\n\ndef _pll_reactor_interact(reactor_value):\n """Register a reactor and emit the card that will drive it."""\n global _pll_reactor_seq\n _pll_reactor_seq += 1\n rid = "r%d" % _pll_reactor_seq\n running = _PllRunning(reactor_value)\n _pll_reactors[rid] = running\n payload = {\n "type": "reactor",\n "id": rid,\n "title": reactor_value.title,\n "tick_rate": reactor_value.tick_rate,\n "ticking": reactor_value.handles("on_tick"),\n "wants_keys": reactor_value.handles("on_key"),\n "wants_mouse": reactor_value.handles("on_mouse"),\n "register": reactor_value.register,\n }\n payload.update(_pll_reactor_view(rid, running))\n payload.pop("ok", None)\n _pll_push(payload)\n reactor_value._shown = True\n return reactor_value\n\n\ndef _pll_reactor_step(rid, event_json):\n """Apply one event to a running reactor and return the new frame."""\n running = _pll_reactors.get(rid)\n if running is None:\n return {"ok": False, "gone": True}\n try:\n event = _rx_json.loads(event_json)\n computed = running.step(event)\n view = _pll_reactor_view(rid, running)\n outgoing = []\n for message in running.current.outgoing() if computed else ():\n try:\n outgoing.append(_rx_json.dumps(message))\n except (TypeError, ValueError):\n return {\n "ok": False,\n "error_type": "TypeError",\n "error_message": (\n "package(...) can only send values the server can read: "\n "numbers, strings, True/False, None, lists and dicts of "\n "those. Got %r." % (message,)\n ),\n "traceback": "",\n }\n view["messages"] = outgoing\n return view\n except BaseException as e:\n return _pll_reactor_failure(e)\n\n\ndef _pll_reactor_seek(rid, index):\n """Show an earlier (or later) frame without applying an event."""\n running = _pll_reactors.get(rid)\n if running is None:\n return {"ok": False, "gone": True}\n try:\n running.seek(int(index) - running.dropped)\n return _pll_reactor_view(rid, running)\n except BaseException as e:\n return _pll_reactor_failure(e)\n\n\ndef _pll_reactor_dispose(rid):\n """Forget a reactor, so its states and trace can be collected."""\n _pll_reactors.pop(rid, None)\n return True\n\n';
227
+ 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 contextlib as _rx_contextlib\nimport json as _rx_json\nimport sys as _rx_sys\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\ndef _pll_rx_suggestion(unknown):\n """" Did you mean `on_tick`?", when one of them is nearly a real one."""\n for given in unknown:\n best = _pll_closest_name(given, _PLL_HANDLER_NAMES)\n if best is not None:\n return " Did you mean `%s`?" % best\n return ""\n\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", "_used")\n\n def __init__(self, handlers, state, trace=(0, None), tracing=False, outbox=()):\n self._h = handlers\n self._state = state\n # The states recorded, newest first, as `(count, (state, older))`:\n # shared between a reactor and the next, so a step adds one cell\n # rather than copying the whole list.\n self._trace = trace\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 # Set when any of its methods is called - a reactor tested with\n # `simulate_trace` was used, though it was never shown.\n self._used = 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 count, cells = self._trace\n trace = (count + 1, (state, cells)) if self._tracing and count < _PLL_MAX_TRACE else self._trace\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 self._used = True\n return self._state\n\n def draw(self):\n """The image for the current state."""\n self._used = True\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 self._used = True\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 self._used = True\n return Reactor(self._h, self._state, (1, (self._state, None)), True, self._outbox)\n\n def stop_trace(self):\n """A new reactor that stops recording, keeping what it has."""\n self._used = True\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 self._used = True\n states = []\n cells = self._trace[1]\n while cells is not None:\n state, cells = cells\n states.append(state)\n states.reverse()\n return states\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\n#: Reactors made during the current run. A reactor that is never started\n#: does nothing and says nothing - the commonest way a universe program\n#: appears to do nothing at all - so the run ends with a note about it.\n#: Cleared at the start of every run by `_pll_reset_reactor_notes`.\n_pll_made_reactors = []\n\n\ndef _pll_reset_reactor_notes():\n del _pll_made_reactors[:]\n\n\ndef _pll_reactor_note():\n """A note about reactors that were built and never started, or "".\n\n Written to stderr at the end of a run: there is nothing to report\n until the program has finished, and nothing wrong with building a\n reactor and starting it later in the same program.\n """\n idle = [r for r in _pll_made_reactors if not r._pll_already_displayed and not r._used]\n if not idle:\n return ""\n if len(idle) == 1:\n return (\n "note: a reactor was made but never started, so nothing ran. "\n "Add `.interact()` to start it.\\n"\n )\n return (\n "note: %d reactors were made but never started, so nothing ran. "\n "Add `.interact()` to start them.\\n" % len(idle)\n )\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 has no handler called %s.%s The handlers are: %s"\n % (\n ", ".join("`%s`" % u for u in sorted(unknown)),\n _pll_rx_suggestion(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(\n "reactor\'s `%s` has to be a function, written as its name "\n "with no brackets after it." % name\n )\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 made = Reactor(handlers, state)\n _pll_made_reactors.append(made)\n return made\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", "main")\n\n def __init__(self, reactor_value):\n self.frames = [(reactor_value, None)]\n # The module the program ran as, which its handlers run as too.\n self.main = _rx_sys.modules["__main__"]\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; the messages to send, as JSON.\n\n None for a frame not newly computed. A *replayed* frame still\n 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. And a stopped world takes no more\n events, from the clock, the student, or the server.\n\n The messages are encoded before the frame is kept, so one that\n cannot be sent leaves the reactor where it was.\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 None\n if self.current.is_stopped():\n return None\n nxt = self.current.react(event)\n messages = [_pll_encode_message(message) for message in nxt.outgoing()]\n del self.frames[ahead:]\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 messages\n\n\ndef _pll_encode_message(message):\n """`message` as the server will get it."""\n try:\n return _rx_json.dumps(message)\n except (TypeError, ValueError):\n raise TypeError(\n "package(...) can only send values the server can read: numbers, "\n "strings, True/False, None, lists and dicts of those. Got %r." % (message,)\n ) from None\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 failure = {"ok": False}\n failure.update(_pll_error_info(exc))\n return failure\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 # What the handlers print goes where the program\'s own output goes, not\n # to the worker\'s console, where a student debugging `on_tick` with\n # `print` would see nothing at all.\n stdout = _PllStream("stdout")\n stderr = _PllStream("stderr")\n try:\n event = _rx_json.loads(event_json)\n with (\n _pll_as_main(running.main),\n _rx_contextlib.redirect_stdout(stdout),\n _rx_contextlib.redirect_stderr(stderr),\n ):\n outgoing = running.step(event)\n view = _pll_reactor_view(rid, running)\n view["messages"] = outgoing or []\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 with _pll_as_main(running.main):\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
228
 
950
229
  // src/common/examplarLib.py
951
- var examplarLib_default = '# Python Language Levels: Examplar support.\n#\n# Examplar (Wrenn, Politz et al.) assesses a *test suite* rather than an\n# implementation. Students write tests first; those tests are run against\n# known-good implementations ("wheats"), where they must all pass, and\n# known-bad ones ("chaffs"), where each must be caught by at least one\n# failing test. A failure on a wheat means the student\'s expectation is\n# wrong; a chaff nobody catches means their suite has a gap.\n#\n# Two primitives live here, both used by the command line and the editor:\n#\n# _pll_examplar_build(sources_json) -> bundle dict (authoring)\n# _pll_examplar_run(test_src, bundle_json) -> results (both hosts)\n#\n# Implementations travel as `.pyc` bytecode, compiled *by this interpreter*.\n# That is deliberate: bytecode is tied to the Python minor version, and\n# compiling inside Pyodide makes the magic number match by construction\n# rather than by asking course staff to keep a matching CPython around.\n#\n# Bytecode hides the source from `inspect.getsource`, and nothing more -\n# `dis` and `co_consts` still tell the whole story to anyone who looks.\n# That is accepted, not overlooked: the autograder holds the grade, so this\n# check needs to be fast and honest rather than secret.\n\nimport ast as _ex_ast\nimport base64 as _ex_b64\nimport importlib.util as _ex_util\nimport marshal as _ex_marshal\n\nEXAMPLAR_FORMAT = 2\n\n\ndef _pll_examplar_magic():\n """Hex of this interpreter\'s bytecode magic, for bundle metadata."""\n return _ex_util.MAGIC_NUMBER.hex()\n\n\ndef _pll_examplar_top_level_names(source, filename):\n """Public top-level names a source file defines.\n\n From the AST, so nothing is executed at authoring time - a wheat is\n still ordinary code someone wrote, and building a bundle should not run\n it.\n """\n names = set()\n for node in _ex_ast.parse(source, filename=filename).body:\n if isinstance(node, (_ex_ast.FunctionDef, _ex_ast.AsyncFunctionDef, _ex_ast.ClassDef)):\n if not node.name.startswith("_"):\n names.add(node.name)\n elif isinstance(node, _ex_ast.Assign):\n for target in node.targets:\n if isinstance(target, _ex_ast.Name) and not target.id.startswith("_"):\n names.add(target.id)\n return names\n\n\ndef _pll_examplar_compile(source, filename):\n """Source -> base64 of a `.pyc` payload (marshalled code, no header).\n\n The import-machinery header is left off on purpose: these are loaded by\n `marshal.loads` rather than by an import hook, so the magic number is\n carried once in the bundle\'s metadata instead of on every blob.\n """\n code = compile(source, filename, "exec")\n return _ex_b64.b64encode(_ex_marshal.dumps(code)).decode("ascii")\n\n\ndef _pll_examplar_build(sources_json):\n """Compile wheats and chaffs into a bundle.\n\n `sources_json` is `{"wheats": {id: source},\n "chaffs": {function: {id: source}}}`.\n Returns a dict with `ok`, and either `bundle` or `error`.\n\n Chaffs are grouped by the function they break, because the student\'s\n report is per function: *these* tests of `initials` caught *these*\n implementations of `initials`. Nothing can infer that grouping reliably\n - a chaff is a whole file, and the functions it leaves alone still differ\n from the wheat\'s by whitespace - so the author states it, by which\n directory the chaff lives in.\n\n Every implementation must define the same public names: a suite is run\n against all of them interchangeably, so one that is missing a function\n would fail for a reason that has nothing to do with the student.\n """\n import json as _ex_json\n\n try:\n sources = _ex_json.loads(sources_json)\n except ValueError as e:\n return {"ok": False, "error": "could not read the sources (%s)" % e}\n\n provides = None\n\n def compile_one(filename, source):\n """-> (blob, error). Also checks the file defines the same names."""\n nonlocal provides\n try:\n names = _pll_examplar_top_level_names(source, filename)\n blob = _pll_examplar_compile(source, filename)\n except SyntaxError as e:\n return None, "%s does not parse: %s (line %s)" % (filename, e.msg, e.lineno)\n if provides is None:\n provides = names\n elif names != provides:\n detail = []\n if provides - names:\n detail.append("missing " + ", ".join(sorted(provides - names)))\n if names - provides:\n detail.append("unexpected " + ", ".join(sorted(names - provides)))\n return None, (\n "%s defines a different set of names than the others (%s). "\n "Every wheat and chaff has to define the same functions."\n % (filename, "; ".join(detail))\n )\n return blob, None\n\n wheats = []\n for ident, source in sorted((sources.get("wheats") or {}).items()):\n blob, error = compile_one("wheats/%s.py" % ident, source)\n if error:\n return {"ok": False, "error": error}\n wheats.append({"id": ident, "pyc": blob})\n\n chaffs = []\n for function, group in sorted((sources.get("chaffs") or {}).items()):\n for ident, source in sorted((group or {}).items()):\n blob, error = compile_one("chaffs/%s/%s.py" % (function, ident), source)\n if error:\n return {"ok": False, "error": error}\n chaffs.append({"id": ident, "targets": function, "pyc": blob})\n\n if not wheats:\n return {"ok": False, "error": "a bundle needs at least one wheat"}\n if not chaffs:\n return {"ok": False, "error": "a bundle needs at least one chaff"}\n\n # A chaff directory that is not one of the functions would silently never\n # be reported - no card would ever mention it.\n targeted = {entry["targets"] for entry in chaffs}\n provides = provides or set()\n stray = sorted(targeted - provides)\n if stray:\n return {\n "ok": False,\n "error": (\n "chaffs/%s is not one of the functions in this bundle (%s). "\n "A chaff goes in a directory named after the function it breaks."\n % (stray[0], ", ".join(sorted(provides)) or "none")\n ),\n }\n # And a function with no chaffs gets a card that can never say anything\n # about how thorough its tests are, which is half of what a card is for.\n barren = sorted(provides - targeted)\n if barren:\n return {\n "ok": False,\n "error": (\n "%s has no chaffs, so nothing would ever measure how thorough a "\n "student\'s tests of it are. Add chaffs/%s/1.py."\n % (barren[0], barren[0])\n ),\n }\n\n import sys as _ex_sys\n\n return {\n "ok": True,\n "bundle": {\n "examplar": EXAMPLAR_FORMAT,\n "built": {\n "python": _ex_sys.version.split()[0],\n "magic": _pll_examplar_magic(),\n },\n "provides": sorted(provides),\n "wheats": wheats,\n "chaffs": chaffs,\n },\n }\n\n\ndef _pll_examplar_free_names(fn_node):\n """Names a function body reads without binding them itself."""\n bound = {arg.arg for arg in fn_node.args.args}\n bound.update(arg.arg for arg in getattr(fn_node.args, "kwonlyargs", []))\n read = set()\n for node in _ex_ast.walk(fn_node):\n if isinstance(node, _ex_ast.Name):\n if isinstance(node.ctx, _ex_ast.Store):\n bound.add(node.id)\n else:\n read.add(node.id)\n return read - bound\n\n\ndef _pll_examplar_attribution(test_source, provides):\n """Which provided names each `test_*` function exercises.\n\n This decides which card a test appears on, so it has to follow the\n student\'s own helpers. A test written as\n\n def check(name, expected):\n assert initials(name) == expected\n\n def test_ada():\n check("Ada Lovelace", "A.L.")\n\n reads `check`, not `initials`, and a direct free-variable scan would\n file it under nothing at all and quietly leave it off every card. So the\n reachable set is closed over the file\'s own top-level functions.\n\n It stops at a provided name rather than descending into it: during the\n phase that name is the *bundle\'s* function, so whatever the student\'s\n version of it happens to call is beside the point.\n\n Not needed for the verdicts themselves, so a parse failure is not fatal.\n """\n out = {}\n try:\n tree = _ex_ast.parse(test_source)\n except SyntaxError:\n return out\n wanted = set(provides)\n reads = {}\n for node in tree.body:\n if isinstance(node, (_ex_ast.FunctionDef, _ex_ast.AsyncFunctionDef)):\n reads[node.name] = _pll_examplar_free_names(node)\n for name in reads:\n if not name.startswith("test_"):\n continue\n reached = set()\n seen = set()\n pending = [name]\n while pending:\n current = pending.pop()\n if current in seen:\n continue\n seen.add(current)\n for read in reads.get(current, ()):\n reached.add(read)\n if read in reads and read not in wanted:\n pending.append(read)\n out[name] = sorted(reached & wanted)\n return out\n\n\ndef _pll_examplar_outcome(exc):\n """How a test ended, and what to say about it."""\n if exc is None:\n return {"outcome": "pass", "message": None}\n if isinstance(exc, AssertionError):\n # Kept for `--verify`, whose reader is the author. A student is shown\n # the test\'s name and not this - see `_pll_examplar_compile_tests`.\n return {"outcome": "fail", "message": str(exc) or "assertion failed"}\n return {\n "outcome": "error",\n "message": "%s: %s" % (type(exc).__name__, exc),\n }\n\n\n# The top-level statements that give a file its *names*. Everything else is\n# the student\'s program, and the phase deliberately does not run that: it\n# runs properly a moment later, once, with the workspace mounted. Running it\n# here as well would mean one copy of every side effect per implementation -\n# four `print`s, four reactors - and a top-level `input()` would block the\n# check forever, because nothing is listening for stdin during it.\n_PLL_EXAMPLAR_DEFINITIONS = (\n _ex_ast.Import,\n _ex_ast.ImportFrom,\n _ex_ast.FunctionDef,\n _ex_ast.AsyncFunctionDef,\n _ex_ast.ClassDef,\n _ex_ast.Assign,\n _ex_ast.AnnAssign,\n _ex_ast.AugAssign,\n)\n\n\ndef _pll_examplar_compile_tests(test_source, filename):\n """Compile the student\'s definitions, one statement at a time.\n\n Through pytest\'s assertion rewriting, so a failure carries the values it\n saw: `assert \'HI!\' == \'hi!\'` rather than "assertion failed".\n\n That message is for the **author**, through `--verify`, where a wheat\n failure means one of their own tests is wrong and there is nothing to\n give away. It never reaches a student: the card names the test and stops\n there, because the rewritten form states the correct answer, and a card\n that hands that over is an oracle rather than a check. The host drops it\n when building the card; it is computed here because the same primitive\n serves both readers.\n\n One code object per statement rather than one for the module, so a\n definition that cannot be loaded here costs only itself instead of the\n whole verdict. `DATA = open("data.csv").read()` is the case that matters:\n the phase runs with the workspace unmounted, so that line cannot work,\n and losing all of a student\'s feedback over it would be a poor trade.\n\n Compiled once and reused for every implementation, so N chaffs still\n cost one compile. Falls back to a plain parse when pytest is not loaded,\n so the verdicts still work - just with poorer messages.\n """\n tree = _ex_ast.parse(test_source, filename=filename)\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n\n rewrite_asserts(tree, test_source.encode("utf-8"), module_path=filename)\n # Same trap as `_pll_run_tests`: do not call `fix_missing_locations`,\n # which copies parent positions onto pytest\'s injected nodes and\n # produces ranges Python 3.12+ rejects.\n _pll_fix_ast_ranges(tree)\n except Exception:\n # Rewriting can leave the tree half-modified, so start again from the\n # source rather than compiling whatever it got to.\n tree = _ex_ast.parse(test_source, filename=filename)\n pieces = []\n for node in tree.body:\n if isinstance(node, _PLL_EXAMPLAR_DEFINITIONS):\n pieces.append(\n compile(_ex_ast.Module(body=[node], type_ignores=[]), filename, "exec")\n )\n return pieces\n\n\ndef _pll_examplar_run_one(test_pieces, code_blob, provided=(), only=None):\n """Run every `test_*` the student defined against one implementation.\n\n Their definitions are loaded first and the implementation second, so a\n name the implementation provides replaces whatever the student wrote.\n That is the Examplar semantics: their tests are judged against this\n code, not against their own attempt.\n\n A definition that raises is skipped rather than fatal, for the reason\n `_pll_examplar_compile_tests` splits them up. Any test that needed it\n then raises its own `NameError`, which the host reports as a test that\n could not run - not as a test that expects the wrong answer.\n\n `only` restricts which tests are run, and chaffs use it: a chaff of\n `shout` must be judged by the tests of `shout` and nothing else. A test\n of `total` fails on it too - a broken `total` test fails on everything -\n and counting that as having caught it would credit the student for a\n signal that has nothing to do with the function.\n """\n namespace = {"__name__": "__main__"}\n for piece in test_pieces:\n try:\n exec(piece, namespace)\n except Exception:\n # Deliberately `Exception`, not `BaseException`: a\n # `KeyboardInterrupt` from the Stop button has to get out.\n continue\n # Which provided names the student has written themselves, recorded\n # *before* the overlay replaces them. The host uses this to decide\n # whether running their tests against their own code makes sense yet:\n # early in the exercise there is no implementation, and doing so would\n # just spray NameErrors.\n student_defines = sorted(\n name for name in provided if callable(namespace.get(name)) or name in namespace\n )\n try:\n exec(_ex_marshal.loads(_ex_b64.b64decode(code_blob)), namespace)\n except BaseException as e:\n return {\n "loaded": False,\n "error_type": type(e).__name__,\n "error_message": "the implementation could not be loaded (%s)" % e,\n "traceback": "",\n "tests": {},\n "student_defines": student_defines,\n }\n\n results = {}\n for name in sorted(namespace):\n if not name.startswith("test_"):\n continue\n if only is not None and name not in only:\n continue\n fn = namespace[name]\n if not callable(fn):\n continue\n try:\n fn()\n results[name] = _pll_examplar_outcome(None)\n except BaseException as e:\n results[name] = _pll_examplar_outcome(e)\n return {"loaded": True, "tests": results, "student_defines": student_defines}\n\n\ndef _pll_examplar_run(test_source, bundle_json):\n """Run a student\'s suite against a bundle, per function and in two phases.\n\n Returns a dict the host turns into one report per provided function.\n Verdicts, for each function separately:\n * a test failing on any wheat -> that test is wrong\n * a chaff with no failing test -> the suite has a gap there\n\n The second phase only happens where the first one *passed*, and it is\n decided per function: `initials` having a wrong test says nothing about\n how well `longest` is tested, so it must not hold that report back. A\n test that disagrees with a correct implementation is wrong, and a wrong\n test fails on *everything* - so it would "catch" every chaff of its\n function, and the number would be an artifact of the bug rather than a\n measure of the suite. A test that *raised* is no better: it raises the\n same way on every implementation. Gating means those chaffs are not even\n run, so nothing about them can be reported before it would mean\n something.\n """\n import json as _ex_json\n\n try:\n bundle = _ex_json.loads(bundle_json)\n except ValueError as e:\n return {"ok": False, "error": "the bundle is not valid JSON (%s)" % e}\n\n if bundle.get("examplar") != EXAMPLAR_FORMAT:\n return {\n "ok": False,\n "error": "this bundle is format %r, but this PLL understands %d"\n % (bundle.get("examplar"), EXAMPLAR_FORMAT),\n }\n built = bundle.get("built") or {}\n if built.get("magic") and built["magic"] != _pll_examplar_magic():\n return {\n "ok": False,\n "error": (\n "this bundle was built for Python %s and cannot run here "\n "(Python %s). It needs rebuilding with a matching pll-python."\n % (built.get("python", "?"), __import__("sys").version.split()[0])\n ),\n }\n\n provides = list(bundle.get("provides") or ())\n # The student\'s own file failing to parse is their problem to fix, and\n # the normal run reports it properly - say so once rather than once per\n # implementation.\n try:\n test_pieces = _pll_examplar_compile_tests(test_source, "hw.py")\n except SyntaxError as e:\n return {\n "ok": False,\n "error": "your file does not parse yet: %s (line %s)" % (e.msg, e.lineno),\n }\n\n attribution = _pll_examplar_attribution(test_source, provides)\n result = {\n "ok": True,\n "provides": provides,\n "attribution": attribution,\n "wheats": [],\n "chaffs": [],\n "chaffs_skipped": [],\n }\n for entry in bundle.get("wheats") or []:\n ran = _pll_examplar_run_one(test_pieces, entry.get("pyc", ""), provides)\n ran["id"] = entry.get("id", "?")\n result["wheats"].append(ran)\n\n # A wheat that will not load is a broken bundle, not a student\'s problem,\n # and there is nothing to measure against - so no phase two anywhere.\n if any(not wheat["loaded"] for wheat in result["wheats"]):\n result["chaffs_skipped"] = sorted(provides)\n return result\n\n # Phase one, per function: every test that exercises it has to have\n # *passed* on every wheat. Nothing weaker counts - a test that raised did\n # not pass either.\n settled = set()\n for function in provides:\n tests = [name for name, names in attribution.items() if function in names]\n if not tests:\n # No tests for it, so nothing to gate and nothing to measure. The\n # host says "no tests yet" rather than "caught 0 of 3", which\n # would read as a failure at something they have not started.\n continue\n if all(\n wheat["tests"].get(name, {}).get("outcome") == "pass"\n for wheat in result["wheats"]\n for name in tests\n ):\n settled.add(function)\n\n for entry in bundle.get("chaffs") or []:\n function = entry.get("targets")\n if function not in settled:\n continue\n # Judged by the tests of *its* function only. A test of another one\n # fails on it as well, and crediting that would score the student\n # for a signal that says nothing about this chaff.\n mine = {name for name, names in attribution.items() if function in names}\n ran = _pll_examplar_run_one(test_pieces, entry.get("pyc", ""), provides, mine)\n ran["id"] = entry.get("id", "?")\n ran["targets"] = function\n result["chaffs"].append(ran)\n result["chaffs_skipped"] = sorted(set(provides) - settled)\n return result\n';
230
+ 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 contextlib as _ex_contextlib\nimport importlib.util as _ex_util\nimport io as _ex_io\nimport marshal as _ex_marshal\nimport sys as _ex_sys\nimport time as _ex_time\nimport types as _ex_types\n\nEXAMPLAR_FORMAT = 2\n\n#: How long one test may run against one implementation - or one of the\n#: student\'s definitions, or an implementation, take to load. Anything that\n#: runs longer is stuck, and the check stops there (`_PllExamplarTimeout`).\n_PLL_EXAMPLAR_SECONDS = 2.0\n\n#: How many trace events pass between looks at the clock.\n_PLL_EXAMPLAR_CLOCK_EVERY = 1024\n\n\nclass _PllExamplarTimeout(BaseException):\n """Raised into code that ran past `_PLL_EXAMPLAR_SECONDS`. A\n `BaseException`, so a test\'s own `except Exception` does not swallow it."""\n\n\n@_ex_contextlib.contextmanager\ndef _pll_examplar_budget():\n """Run the body with `_PLL_EXAMPLAR_SECONDS` to finish in.\n\n A trace function rather than the interrupt channel Stop uses, so it\n works wherever the check runs, with or without one. A loop in Python\n code is caught; one inside a single call into C is not.\n """\n deadline = _ex_time.monotonic() + _PLL_EXAMPLAR_SECONDS\n events = [0]\n\n def tracer(frame, event, arg):\n events[0] += 1\n if events[0] % _PLL_EXAMPLAR_CLOCK_EVERY == 0 and _ex_time.monotonic() > deadline:\n raise _PllExamplarTimeout()\n return tracer\n\n previous = _ex_sys.gettrace()\n _ex_sys.settrace(tracer)\n try:\n yield\n finally:\n _ex_sys.settrace(previous)\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 arguments = fn_node.args\n bound = {arg.arg for arg in arguments.posonlyargs + arguments.args + arguments.kwonlyargs}\n bound.update(arg.arg for arg in (arguments.vararg, arguments.kwarg) if arg is not None)\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_raised_in_implementation(exc, student_file):\n """Whether `exc` came from the implementation rather than the student\'s\n own code: the innermost frame of either\'s, from the inside out. A\n library the implementation called counts as the implementation."""\n frames = []\n tb = exc.__traceback__\n while tb is not None:\n frames.append(tb.tb_frame.f_code.co_filename)\n tb = tb.tb_next\n for filename in reversed(frames):\n if filename == student_file:\n return False\n if filename.startswith(("wheats/", "chaffs/")):\n return True\n return False\n\n\ndef _pll_examplar_outcome(exc, student_file):\n """How a test ended, and what to say about it.\n\n A test the implementation itself refused - `shout("")` raising its own\n `ValueError` - disagrees with it, as a wrong expectation does. Its\n message is the implementation\'s, so it is kept for `--verify` and never\n shown to a student. Only an error from the student\'s own code is one\n their test "could not run" past.\n """\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 message = "%s: %s" % (type(exc).__name__, exc)\n if _pll_examplar_raised_in_implementation(exc, student_file):\n return {"outcome": "fail", "message": message}\n return {"outcome": "error", "message": message}\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 """-> [(line, code)], one for each definition: see below."""\n return [\n (node.lineno, code)\n for node, code in _pll_examplar_compiled_definitions(test_source, filename)\n ]\n\n\ndef _pll_examplar_compiled_definitions(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 # Not `fix_missing_locations`, which copies parent positions onto\n # pytest\'s injected nodes and 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 # Recorded and dropped, like the test phase\'s: the run that follows\n # compiles the same file and says each warning once.\n try:\n with _pll_recording_compile_warnings():\n for node in tree.body:\n if isinstance(node, _PLL_EXAMPLAR_DEFINITIONS):\n pieces.append((\n node,\n compile(_ex_ast.Module(body=[node], type_ignores=[]), filename, "exec"),\n ))\n finally:\n del _pll_compile_warnings[:]\n return pieces\n\n\ndef _pll_examplar_run_one(test_pieces, code_blob, student_file, 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 Run in a module made `__main__`, starting with the names a run starts\n with - the libraries\' among them - so an image assignment\'s tests can\n call `image_width` and its implementations `circle`. Whatever runs past\n `_PLL_EXAMPLAR_SECONDS` ends it: `timed_out` names the test, or None for\n a definition or the implementation\'s own loading.\n """\n module = _ex_types.ModuleType("__main__")\n module.__dict__.update(_pll_initial_globals)\n namespace = module.__dict__\n # The student\'s definitions that could not be loaded here, and why.\n unloaded = []\n results = {}\n\n def ended(timed_out, **extra):\n return {\n "loaded": True,\n "tests": results,\n "student_defines": student_defines,\n "unloaded": unloaded,\n "timed_out": timed_out,\n **extra,\n }\n\n student_defines = []\n with _pll_as_main(module):\n for line, piece in test_pieces:\n try:\n with _pll_examplar_budget():\n exec(piece, namespace)\n except _PllExamplarTimeout:\n return ended(None)\n except Exception as e:\n # Deliberately `Exception`, not `BaseException`: a\n # `KeyboardInterrupt` from the Stop button has to get out.\n unloaded.append({"line": line, "error": "%s: %s" % (type(e).__name__, e)})\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(name for name in provided if name in namespace)\n try:\n with _pll_examplar_budget():\n exec(_ex_marshal.loads(_ex_b64.b64decode(code_blob)), namespace)\n except KeyboardInterrupt:\n # A Stop, not a broken implementation, which the card would report\n # as "the bundle may need rebuilding".\n raise\n except _PllExamplarTimeout:\n return ended(None)\n except BaseException as e:\n return {\n **ended(None),\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 "timed_out": False,\n }\n\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 with _pll_examplar_budget():\n fn()\n results[name] = _pll_examplar_outcome(None, student_file)\n except KeyboardInterrupt:\n # A Stop ends the whole check. Recorded as this test\'s error, it\n # let the next implementation run the same test - which, if it\n # loops, needed another Stop, and another, one per implementation.\n raise\n except _PllExamplarTimeout:\n return ended(name)\n except BaseException as e:\n results[name] = _pll_examplar_outcome(e, student_file)\n return ended(False)\n\n\ndef _pll_examplar_run(test_source, bundle_json, filename="hw.py"):\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 Two things end the whole check rather than one card: an implementation\n that will not load - the bundle is broken - and anything that runs past\n `_PLL_EXAMPLAR_SECONDS` (`timed_out`), which no later result could be\n trusted after. What the code prints is dropped: the file\'s own tests,\n which run next, show it once.\n """\n with _ex_contextlib.redirect_stdout(_ex_io.StringIO()), _ex_contextlib.redirect_stderr(_ex_io.StringIO()):\n return _pll_examplar_judge(test_source, bundle_json, filename)\n\n\ndef _pll_examplar_judge(test_source, bundle_json, filename):\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 not compiling is their problem to fix, and the\n # normal run reports it properly - say so once rather than once per\n # implementation. Some code parses and does not compile (`case Boa:`),\n # so this covers both and says neither.\n try:\n test_pieces = _pll_examplar_compile_tests(test_source, filename)\n except SyntaxError as e:\n return {\n "ok": False,\n "error": "your file has a syntax error: %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 "timed_out": None,\n }\n\n def stopped_by(ran, kind):\n """Record a timeout as the end of the check; True if there was one."""\n if ran["timed_out"] is False:\n return False\n result["timed_out"] = {\n "kind": kind,\n "id": ran["id"],\n "targets": ran.get("targets"),\n "test": ran["timed_out"],\n "seconds": _PLL_EXAMPLAR_SECONDS,\n }\n result["chaffs_skipped"] = sorted(provides)\n return True\n\n for entry in bundle.get("wheats") or []:\n ran = _pll_examplar_run_one(test_pieces, entry.get("pyc", ""), filename, provides)\n ran["id"] = entry.get("id", "?")\n result["wheats"].append(ran)\n if stopped_by(ran, "wheat"):\n return result\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", ""), filename, provides, mine)\n ran["id"] = entry.get("id", "?")\n ran["targets"] = function\n result["chaffs"].append(ran)\n if stopped_by(ran, "chaff"):\n return result\n result["chaffs_skipped"] = sorted(set(provides) - settled)\n return result\n';
952
231
 
953
- // src/common/pyodideRunner.ts
954
- var PYODIDE_BOOTSTRAP_PY = pyodideBootstrap_default;
955
- var PLL_IMAGE_LIB_PY = imageLib_default;
956
- var PLL_TABLE_LIB_PY = tableLib_default;
957
- var PLL_REACTOR_LIB_PY = reactorLib_default;
958
- var PLL_EXAMPLAR_LIB_PY = examplarLib_default;
959
- var NETWORK_IMPORT_RE = /(^|\n)[ \t]*(?:import|from)[ \t]+(?:pandas|requests|urllib|urllib3|httpx|aiohttp|http)\b/;
960
- var PYODIDE_HTTP_PATCH_PY = "import pyodide_http as _pll_ph; _pll_ph.patch_all()";
961
- var PYODIDE_INSTALL_PY = `
962
- import sys as _sys, types as _types
232
+ // src/common/install.py
233
+ var install_default = '# The install step, run after the libraries: register `pll.image`,\n# `pll.table` and `pll.reactor` as importable modules, and copy their public\n# names into the per-session globals template (`_pll_initial_globals`), so\n# every file\'s runs and prompt lines can use `circle(...)` / `table(...)`\n# with no import.\n\nimport sys as _sys, types as _types\n\n_pll_module = _types.ModuleType("pll")\n_pll_image_module = _types.ModuleType("pll.image")\n_pll_table_module = _types.ModuleType("pll.table")\n_pll_reactor_module = _types.ModuleType("pll.reactor")\nfor _name in PLL_IMAGE_EXPORTS:\n setattr(_pll_image_module, _name, globals()[_name])\nfor _name in PLL_TABLE_EXPORTS:\n setattr(_pll_table_module, _name, globals()[_name])\nfor _name in PLL_REACTOR_EXPORTS:\n setattr(_pll_reactor_module, _name, globals()[_name])\n_pll_module.image = _pll_image_module\n_pll_module.table = _pll_table_module\n_pll_module.reactor = _pll_reactor_module\n_sys.modules["pll"] = _pll_module\n_sys.modules["pll.image"] = _pll_image_module\n_sys.modules["pll.table"] = _pll_table_module\n_sys.modules["pll.reactor"] = _pll_reactor_module\n\n# matplotlib draws with PLL\'s backend (`matplotlibBackend.py`), served from\n# its source under that name, so `plt.show()` puts a figure in the panel.\nimport importlib.abc as _pll_importlib_abc\nimport importlib.util as _pll_importlib_util\nimport os as _pll_install_os\n\n\nclass _PllMatplotlibFinder(_pll_importlib_abc.MetaPathFinder, _pll_importlib_abc.Loader):\n def find_spec(self, name, path=None, target=None):\n if name != "_pll_matplotlib":\n return None\n return _pll_importlib_util.spec_from_loader(name, self)\n\n def create_module(self, spec):\n return None\n\n def exec_module(self, module):\n module._pll_show_figure = _pll_show_figure\n exec(compile(_pll_matplotlib_backend_source, _PLL_MATPLOTLIB_FILE, "exec"), module.__dict__)\n\n\n_sys.meta_path.append(_PllMatplotlibFinder())\n_pll_install_os.environ["MPLBACKEND"] = "module://_pll_matplotlib"\n\n# Add image + table library names to the per-session globals template.\n# Each new session is initialized as a copy of this template, so every\n# file\'s Run File / REPL prompt sees these names without explicit imports.\nfor _name in PLL_IMAGE_EXPORTS:\n _pll_initial_globals[_name] = globals()[_name]\nfor _name in PLL_TABLE_EXPORTS:\n _pll_initial_globals[_name] = globals()[_name]\nfor _name in PLL_REACTOR_EXPORTS:\n _pll_initial_globals[_name] = globals()[_name]\ndel _name\n';
963
234
 
964
- _pll_module = _types.ModuleType("pll")
965
- _pll_image_module = _types.ModuleType("pll.image")
966
- _pll_table_module = _types.ModuleType("pll.table")
967
- _pll_reactor_module = _types.ModuleType("pll.reactor")
968
- for _name in PLL_IMAGE_EXPORTS:
969
- setattr(_pll_image_module, _name, globals()[_name])
970
- for _name in PLL_TABLE_EXPORTS:
971
- setattr(_pll_table_module, _name, globals()[_name])
972
- for _name in PLL_REACTOR_EXPORTS:
973
- setattr(_pll_reactor_module, _name, globals()[_name])
974
- _pll_module.image = _pll_image_module
975
- _pll_module.table = _pll_table_module
976
- _pll_module.reactor = _pll_reactor_module
977
- _sys.modules["pll"] = _pll_module
978
- _sys.modules["pll.image"] = _pll_image_module
979
- _sys.modules["pll.table"] = _pll_table_module
980
- _sys.modules["pll.reactor"] = _pll_reactor_module
235
+ // src/common/http.py
236
+ var http_default = '# Reading URLs with urllib, requests and pandas: pyodide-http\'s patches,\n# with PLL\'s own transport under them. Run after the `pyodide-http`\n# package is loaded, each time a program imports a module that reads URLs,\n# so `requests` is patched once it is there too; every step is idempotent.\n#\n# What PLL\'s transport changes: the bytes arrive as they were sent, as an\n# `arraybuffer` (pyodide-http decodes a string as ISO-8859-15 outside a\n# browser\'s worker, which is not the Latin-1 the desktop\'s XHR gives, and\n# eight byte values came back wrong); a timeout is honoured wherever it is\n# given; a Stop ends the wait on the desktop; and a request that fails is\n# urllib\'s `URLError` or requests\' `ConnectionError`, as in CPython, with\n# what went wrong. `urlopen` raises `HTTPError` for an answer of 400 and\n# above, as CPython\'s does, so pandas does not read an error page as data.\n\nimport email.parser as _pll_http_email\nimport http.client as _pll_http_client\nimport io as _pll_http_io\nimport socket as _pll_http_socket\nimport urllib.error as _pll_http_error\nimport urllib.request as _pll_http_request\n\nimport pyodide_http as _pll_ph\nimport pyodide_http._core as _pll_ph_core\nimport pyodide_http._urllib as _pll_ph_urllib\n\n\ndef _pll_http_send(request, stream=False):\n """pyodide-http\'s `send`, as PLL makes every request."""\n from js import URLSearchParams, XMLHttpRequest\n from pyodide.ffi import JsException, to_js\n\n url = request.url\n if request.params:\n params = URLSearchParams.new()\n for key, value in request.params.items():\n params.append(key, value)\n url += "?" + params.toString()\n xhr = XMLHttpRequest.new()\n xhr.open(request.method, url, False)\n xhr.responseType = "arraybuffer"\n if request.timeout:\n xhr.timeout = max(1, int(request.timeout * 1000))\n view = globals().get("_pll_interrupt_view")\n if view is not None and getattr(XMLHttpRequest, "pllNode", False):\n xhr.pllInterrupt = view\n for name, value in request.headers.items():\n if name.lower() not in _pll_ph_core.HEADERS_TO_IGNORE:\n xhr.setRequestHeader(name, value)\n failure = None\n try:\n xhr.send(to_js(request.body))\n except JsException as e:\n failure = (getattr(e, "name", ""), str(getattr(e, "message", "") or e))\n if failure is not None:\n kind, message = failure\n if kind == "AbortError":\n # A Stop, taken now rather than at the next bytecode check - and\n # outside the `except`, so it is not raised from the JavaScript\n # error, whose place is in PLL\'s worker.\n view[0] = 0\n _pll_on_sigint(2, None)\n if kind == "TimeoutError":\n raise _pll_ph_core._StreamingTimeout(message or "timed out", request=request)\n raise _pll_ph_core._StreamingError(message or "could not connect", request=request)\n headers = dict(_pll_http_email.Parser().parsestr(xhr.getAllResponseHeaders()))\n response = xhr.response\n body = bytes(response.to_bytes()) if response is not None else b""\n return _pll_ph_core.Response(status_code=xhr.status, headers=headers, body=body)\n\n\ndef _pll_urlopen(url, data=None, timeout=_pll_http_socket._GLOBAL_DEFAULT_TIMEOUT, *, context=None):\n """`urllib.request.urlopen`, as CPython\'s behaves, over PLL\'s transport."""\n method = "POST" if data is not None else "GET"\n headers = {}\n if isinstance(url, _pll_http_request.Request):\n if data is None:\n data = url.data\n method = url.get_method()\n headers = dict(url.header_items())\n url = url.full_url\n request = _pll_ph_core.Request(method, url, headers=headers, body=data)\n if isinstance(timeout, (int, float)) and not isinstance(timeout, bool):\n request.timeout = timeout\n try:\n answer = _pll_http_send(request)\n except _pll_ph_core._StreamingTimeout as e:\n raise _pll_http_error.URLError(TimeoutError(e.message)) from None\n except _pll_ph_core._StreamingError as e:\n raise _pll_http_error.URLError(e.message) from None\n # A response read from bytes, as pyodide-http builds one. Without its\n # length: when the body was compressed, that is the compressed length.\n lines = [b"HTTP/1.1 %d %s" % (answer.status_code, _pll_http_client.responses.get(answer.status_code, "").encode("ascii"))]\n for key, value in answer.headers.items():\n if key.lower() != "content-length":\n lines.append(("%s: %s" % (key, value)).encode("latin-1", "replace"))\n response = _pll_http_client.HTTPResponse(_pll_ph_urllib.FakeSock(b"\\r\\n".join(lines) + b"\\r\\n\\r\\n" + answer.body))\n response.begin()\n response.url = url\n if answer.status_code >= 400:\n raise _pll_http_error.HTTPError(url, answer.status_code, response.reason, response.headers, response)\n return response\n\n\ndef _pll_opener_open(self, fullurl, data=None, timeout=_pll_http_socket._GLOBAL_DEFAULT_TIMEOUT):\n return _pll_urlopen(fullurl, data, timeout)\n\n\ndef _pll_patch_http():\n _pll_ph.patch_all()\n _pll_ph_core.send = _pll_http_send\n _pll_ph_urllib.send = _pll_http_send\n _pll_http_request.urlopen = _pll_urlopen\n _pll_http_request.OpenerDirector.open = _pll_opener_open\n try:\n import pyodide_http._requests as _pll_ph_requests\n import requests as _pll_requests\n except ImportError:\n return\n _pll_ph_requests.send = _pll_http_send\n adapter = _pll_ph_requests.PyodideHTTPAdapter\n if getattr(adapter.send, "_pll", False):\n return\n original = adapter.send\n\n def send(self, request, **kwargs):\n # pyodide-http raises requests\' error with no message; the one it\n # was raised from says what happened.\n try:\n return original(self, request, **kwargs)\n except _pll_requests.ConnectionError as e:\n cause = e.__context__\n if not e.args and isinstance(cause, _pll_ph_core._RequestError):\n raise type(e)(cause.message, request=request) from None\n raise\n\n send._pll = True\n adapter.send = send\n\n\n_pll_patch_http()\n';
981
237
 
982
- # Add image + table library names to the per-session globals template.
983
- # Each new session is initialized as a copy of this template, so every
984
- # file's Run File / REPL prompt sees these names without explicit imports.
985
- for _name in PLL_IMAGE_EXPORTS:
986
- _pll_initial_globals[_name] = globals()[_name]
987
- for _name in PLL_TABLE_EXPORTS:
988
- _pll_initial_globals[_name] = globals()[_name]
989
- for _name in PLL_REACTOR_EXPORTS:
990
- _pll_initial_globals[_name] = globals()[_name]
991
- del _name
992
- `;
238
+ // src/common/matplotlibBackend.py
239
+ var matplotlibBackend_default = "# PLL's matplotlib backend, `module://_pll_matplotlib` (set as MPLBACKEND\n# by the install step). Agg draws; `show()` puts each open figure where the\n# program's other pictures go - the interactions panel, or the command\n# line's output - and closes it, as a notebook does.\n#\n# Pyodide's own backend draws into a web page's `document`, which a worker\n# does not have, so without this `plt.plot` failed with \"cannot import name\n# 'document' from 'js'\".\n#\n# `_pll_show_figure` is PLL's, put in this module's namespace before it runs.\n\nfrom matplotlib._pylab_helpers import Gcf\nfrom matplotlib.backend_bases import FigureManagerBase as FigureManager\nfrom matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas\n\n\ndef show(*args, **kwargs):\n for manager in Gcf.get_all_fig_managers():\n _pll_show_figure(manager.canvas.figure)\n Gcf.destroy_all()\n";
993
240
 
994
- // src/common/interruptBuffer.ts
995
- function clearInterrupt(sab) {
996
- Atomics.store(new Uint8Array(sab), 0, 0);
241
+ // src/common/pythonFiles.ts
242
+ var PLL_LIBRARY_FILES = {
243
+ image: "<pll:image>",
244
+ table: "<pll:table>",
245
+ reactor: "<pll:reactor>",
246
+ examplar: "<pll:examplar>",
247
+ matplotlib: "<pll:matplotlib>"
248
+ };
249
+ var PLL_INSTALL_FILE = "<pll:install>";
250
+ var PLL_HTTP_FILE = "<pll:http>";
251
+ function bootstrapFile(name) {
252
+ return `<pll:bootstrap/${name}>`;
997
253
  }
998
254
 
255
+ // src/common/pythonSources.ts
256
+ var PLL_BOOTSTRAP_PY = [
257
+ { file: bootstrapFile("typeChecking"), source: typeChecking_default },
258
+ { file: bootstrapFile("errorInfo"), source: errorInfo_default },
259
+ { file: bootstrapFile("codeFacts"), source: codeFacts_default },
260
+ { file: bootstrapFile("sessions"), source: sessions_default },
261
+ { file: bootstrapFile("stop"), source: stop_default },
262
+ { file: bootstrapFile("compile"), source: compile_default },
263
+ { file: bootstrapFile("libraryHelpers"), source: libraryHelpers_default },
264
+ { file: bootstrapFile("packages"), source: packages_default },
265
+ { file: bootstrapFile("running"), source: running_default },
266
+ { file: bootstrapFile("tests"), source: tests_default },
267
+ { file: bootstrapFile("staticAnalysis"), source: staticAnalysis_default },
268
+ { file: bootstrapFile("imports"), source: imports_default }
269
+ ];
270
+ var PLL_IMAGE_LIB_PY = { file: PLL_LIBRARY_FILES.image, source: imageLib_default };
271
+ var PLL_TABLE_LIB_PY = { file: PLL_LIBRARY_FILES.table, source: tableLib_default };
272
+ var PLL_REACTOR_LIB_PY = { file: PLL_LIBRARY_FILES.reactor, source: reactorLib_default };
273
+ var PLL_EXAMPLAR_LIB_PY = { file: PLL_LIBRARY_FILES.examplar, source: examplarLib_default };
274
+ var PLL_MATPLOTLIB_PY = { file: PLL_LIBRARY_FILES.matplotlib, source: matplotlibBackend_default };
275
+ var PYODIDE_HTTP_PATCH_PY = { file: PLL_HTTP_FILE, source: http_default };
276
+ var PYODIDE_INSTALL_PY = { file: PLL_INSTALL_FILE, source: install_default };
277
+
999
278
  // vendor/python/typeguard-4.6.0-py3-none-any.whl
1000
279
  var typeguard_4_6_0_py3_none_any_default = "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";
1001
280
 
@@ -1009,54 +288,326 @@ var VENDORED_WHEELS = [
1009
288
  { name: "typeguard.whl", base64: typeguard_4_6_0_py3_none_any_default }
1010
289
  ];
1011
290
 
291
+ // src/common/pythonInstall.ts
292
+ function installPll(instance, { interruptBuffer = null } = {}) {
293
+ for (const python of PLL_BOOTSTRAP_PY) {
294
+ run(instance, python);
295
+ }
296
+ if (interruptBuffer) {
297
+ const view = new Uint8Array(interruptBuffer);
298
+ instance.globals.set("_pll_interrupt_view", view);
299
+ instance.globals.set("_pll_wait_for_stop", waitForStop(view));
300
+ }
301
+ instance.globals.set("_pll_matplotlib_backend_source", PLL_MATPLOTLIB_PY.source);
302
+ instance.globals.set("_PLL_MATPLOTLIB_FILE", PLL_MATPLOTLIB_PY.file);
303
+ run(instance, PLL_IMAGE_LIB_PY);
304
+ run(instance, PLL_TABLE_LIB_PY);
305
+ run(instance, PLL_REACTOR_LIB_PY);
306
+ run(instance, PLL_EXAMPLAR_LIB_PY);
307
+ run(instance, PYODIDE_INSTALL_PY);
308
+ enableTypeChecking(instance);
309
+ ensureWorkDir(instance.FS);
310
+ }
311
+ function run(instance, python) {
312
+ instance.runPython(python.source, { filename: python.file });
313
+ }
314
+ function decodeBase64(base64) {
315
+ const binary = atob(base64);
316
+ const bytes = new Uint8Array(binary.length);
317
+ for (let i = 0; i < binary.length; i++) {
318
+ bytes[i] = binary.charCodeAt(i);
319
+ }
320
+ return bytes;
321
+ }
322
+ function enableTypeChecking(instance) {
323
+ try {
324
+ try {
325
+ instance.FS.mkdir(PLL_VENDOR_DIR);
326
+ } catch {
327
+ }
328
+ for (const wheel of VENDORED_WHEELS) {
329
+ instance.FS.writeFile(`${PLL_VENDOR_DIR}/${wheel.name}`, decodeBase64(wheel.base64));
330
+ }
331
+ const enable = instance.globals.get("_pll_enable_type_checking");
332
+ try {
333
+ enable();
334
+ } finally {
335
+ enable.destroy?.();
336
+ }
337
+ } catch {
338
+ }
339
+ }
340
+ function waitForStop(view) {
341
+ const cell = new Int32Array(new SharedArrayBuffer(4));
342
+ return (seconds) => {
343
+ const end = Date.now() + seconds * 1e3;
344
+ for (; ; ) {
345
+ if (view[0] === INTERRUPT_SIGINT) return true;
346
+ const left = end - Date.now();
347
+ if (left <= 0) return false;
348
+ Atomics.wait(cell, 0, 0, Math.min(left, 20));
349
+ }
350
+ };
351
+ }
352
+
1012
353
  // src/common/stdinBuffer.ts
1013
354
  var STDIN_SAB_BYTES = 64 * 1024;
1014
355
  var STDIN_STATE_INDEX = 0;
1015
356
  var STDIN_LENGTH_INDEX = 1;
1016
- var STDIN_PAYLOAD_OFFSET = 8;
357
+ var STDIN_REQUEST_INDEX = 2;
358
+ var STDIN_PAYLOAD_OFFSET = 16;
1017
359
  var STDIN_STATE_WAITING = 0;
1018
360
  var STDIN_STATE_EOF = 2;
1019
- function decodeSharedBytes(sab, offset, length) {
1020
- const copy = new Uint8Array(length);
1021
- if (length > 0) {
1022
- copy.set(new Uint8Array(sab, offset, length));
1023
- }
1024
- return new TextDecoder().decode(copy);
1025
- }
1026
- function waitForStdinLine(sab, requestInput) {
361
+ var STDIN_STATE_INTERRUPTED = 3;
362
+ function waitForStdin(sab, requestInput) {
1027
363
  const state = new Int32Array(sab);
364
+ const request = Atomics.load(state, STDIN_REQUEST_INDEX) + 1;
365
+ Atomics.store(state, STDIN_REQUEST_INDEX, request);
1028
366
  Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_WAITING);
1029
- requestInput();
367
+ requestInput(request);
1030
368
  Atomics.wait(state, STDIN_STATE_INDEX, STDIN_STATE_WAITING);
1031
369
  const next = Atomics.load(state, STDIN_STATE_INDEX);
1032
370
  if (next === STDIN_STATE_EOF) {
1033
371
  return null;
1034
372
  }
373
+ if (next === STDIN_STATE_INTERRUPTED) {
374
+ return "interrupted";
375
+ }
1035
376
  const n = Atomics.load(state, STDIN_LENGTH_INDEX);
1036
377
  const max = sab.byteLength - STDIN_PAYLOAD_OFFSET;
1037
- const len = n > 0 && n <= max ? n : 0;
1038
- return decodeSharedBytes(sab, STDIN_PAYLOAD_OFFSET, len);
378
+ const length = n > 0 && n <= max ? n : 0;
379
+ const copy = new Uint8Array(length);
380
+ copy.set(new Uint8Array(sab, STDIN_PAYLOAD_OFFSET, length));
381
+ return copy;
382
+ }
383
+
384
+ // src/common/errorText.ts
385
+ function errorText(err) {
386
+ return err instanceof Error ? err.message : String(err);
387
+ }
388
+
389
+ // src/common/editDistance.ts
390
+ function editDistance(a, b) {
391
+ let twoBack = [];
392
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
393
+ for (let i = 1; i <= a.length; i++) {
394
+ const current = [i];
395
+ for (let j = 1; j <= b.length; j++) {
396
+ current[j] = Math.min(
397
+ previous[j] + 1,
398
+ current[j - 1] + 1,
399
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
400
+ );
401
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
402
+ current[j] = Math.min(current[j], twoBack[j - 2] + 1);
403
+ }
404
+ }
405
+ twoBack = previous;
406
+ previous = current;
407
+ }
408
+ return previous[b.length];
409
+ }
410
+
411
+ // src/common/level.ts
412
+ var LEVEL_RAW = "raw";
413
+ var LEVEL_BEGINNER = "beginner";
414
+ var LEVEL_INTERMEDIATE = "intermediate";
415
+ var LEVEL_ADVANCED = "advanced";
416
+ var LEVEL_NAMES = [LEVEL_RAW, LEVEL_BEGINNER, LEVEL_INTERMEDIATE, LEVEL_ADVANCED];
417
+ var DEFAULT_LEVEL = LEVEL_RAW;
418
+ function isLevel(name) {
419
+ return LEVEL_NAMES.includes(name);
420
+ }
421
+ var HEADER_RE = /^#\s*level\s+([a-z]+)\s*$/;
422
+ function parseLevel(source) {
423
+ const lines = source.split(/\r?\n/);
424
+ for (const raw of lines) {
425
+ const line = raw.trim();
426
+ if (line.length === 0) {
427
+ continue;
428
+ }
429
+ const match = HEADER_RE.exec(line);
430
+ if (!match) {
431
+ return DEFAULT_LEVEL;
432
+ }
433
+ const name = match[1];
434
+ return isLevel(name) ? name : DEFAULT_LEVEL;
435
+ }
436
+ return DEFAULT_LEVEL;
437
+ }
438
+ function levelHeaderProblem(source) {
439
+ const lines = source.split(/\r?\n/);
440
+ const valid = new RegExp(`^#\\s*level\\s+(?:${LEVEL_NAMES.join("|")})\\s*$`);
441
+ const joined = new RegExp(`^#\\s*level(${LEVEL_NAMES.join("|")})\\s*$`);
442
+ for (let i = 0; i < lines.length; i++) {
443
+ const line = lines[i].trim();
444
+ if (line.length === 0) {
445
+ continue;
446
+ }
447
+ const squashed = joined.exec(line);
448
+ if (squashed !== null) {
449
+ return {
450
+ line: i + 1,
451
+ message: `\`#level\` needs a space before \`${squashed[1]}\`: \`#level ${squashed[1]}\`.`,
452
+ howToFix: [`Write it as \`#level ${squashed[1]}\`, with a space.`]
453
+ };
454
+ }
455
+ const attempt = /^#\s*level\b(.*)$/.exec(line);
456
+ if (attempt === null) {
457
+ return misplacedHeader(lines, i, valid);
458
+ }
459
+ if (valid.test(line)) {
460
+ return null;
461
+ }
462
+ const named2 = attempt[1].trim();
463
+ if (named2.length === 0) {
464
+ return {
465
+ line: i + 1,
466
+ message: `\`#level\` needs a level after it: ${levelList()}.`,
467
+ howToFix: [`Name one of ${levelList()}, or leave the line out.`]
468
+ };
469
+ }
470
+ const suggestion = closestLevel(named2);
471
+ return {
472
+ line: i + 1,
473
+ message: `\`${named2}\` is not a level` + (suggestion ? `. Did you mean \`${suggestion}\`?` : `. The levels are ${levelList()}.`),
474
+ howToFix: suggestion ? [`Write \`#level ${suggestion}\` on the first line, in lower case.`] : [`Name one of ${levelList()}, or leave the line out.`]
475
+ };
476
+ }
477
+ return null;
478
+ }
479
+ function misplacedHeader(lines, from, valid) {
480
+ const comments = commentLines(lines);
481
+ let underCode = false;
482
+ for (let i = from; i < lines.length; i++) {
483
+ const line = lines[i].trim();
484
+ if (line.length === 0) {
485
+ continue;
486
+ }
487
+ if (!comments[i]) {
488
+ underCode = true;
489
+ continue;
490
+ }
491
+ if (valid.test(line)) {
492
+ return {
493
+ line: i + 1,
494
+ message: `\`${line}\` only counts on the first line, so none of its checks ran.`,
495
+ howToFix: [
496
+ underCode ? "Move it to the very first line of the file, above the code." : "Move it to the very top of the file, above the comments.",
497
+ "Leave the line out altogether to run the file as ordinary Python."
498
+ ]
499
+ };
500
+ }
501
+ }
502
+ return null;
503
+ }
504
+ function commentLines(lines) {
505
+ const comments = [];
506
+ let open = null;
507
+ for (const line of lines) {
508
+ comments.push(open === null && line.trimStart().startsWith("#"));
509
+ let i = 0;
510
+ while (i < line.length) {
511
+ if (open !== null) {
512
+ const close = closingQuote(line, i, open);
513
+ if (close < 0) break;
514
+ i = close + open.length;
515
+ open = null;
516
+ continue;
517
+ }
518
+ const c = line[i];
519
+ if (c === "#") break;
520
+ if (c === '"' || c === "'") {
521
+ if (line.startsWith(c.repeat(3), i)) {
522
+ open = c.repeat(3);
523
+ i += 3;
524
+ continue;
525
+ }
526
+ const close = closingQuote(line, i + 1, c);
527
+ i = close < 0 ? line.length : close + 1;
528
+ continue;
529
+ }
530
+ i += 1;
531
+ }
532
+ }
533
+ return comments;
534
+ }
535
+ function closingQuote(line, from, quote) {
536
+ for (let i = from; i < line.length; i++) {
537
+ if (line[i] === "\\") {
538
+ i += 1;
539
+ } else if (line.startsWith(quote, i)) {
540
+ return i;
541
+ }
542
+ }
543
+ return -1;
544
+ }
545
+ function levelList() {
546
+ return LEVEL_NAMES.map((name) => `\`${name}\``).join(", ");
547
+ }
548
+ function closestLevel(named2) {
549
+ const lower = named2.toLowerCase();
550
+ let best = null;
551
+ let bestDistance = Infinity;
552
+ for (const level of LEVEL_NAMES) {
553
+ const distance = editDistance(lower, level);
554
+ if (distance < bestDistance) {
555
+ best = level;
556
+ bestDistance = distance;
557
+ }
558
+ }
559
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
560
+ }
561
+
562
+ // src/common/onceSuccessful.ts
563
+ function onceSuccessful(load) {
564
+ let cached = null;
565
+ return () => {
566
+ if (cached === null) {
567
+ cached = load().catch((err) => {
568
+ cached = null;
569
+ throw err;
570
+ });
571
+ }
572
+ return cached;
573
+ };
1039
574
  }
1040
575
 
1041
576
  // src/common/workerHost.ts
1042
- var LIVE_FLUSH_MS = 50;
1043
- function decodeBase64(base64) {
1044
- const binary = atob(base64);
1045
- const bytes = new Uint8Array(binary.length);
1046
- for (let i = 0; i < binary.length; i++) {
1047
- bytes[i] = binary.charCodeAt(i);
577
+ function nullForNone(value) {
578
+ if (value === void 0) return null;
579
+ if (Array.isArray(value)) return value.map(nullForNone);
580
+ if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
581
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, nullForNone(inner)]));
1048
582
  }
1049
- return bytes;
583
+ return value;
584
+ }
585
+ function errorKind(err) {
586
+ if (interpreterFinished(err)) return "finished";
587
+ if (err?.type === "KeyboardInterrupt") return "interrupted";
588
+ return "failed";
1050
589
  }
590
+ function interpreterFinished(err) {
591
+ return err?.pyodide_fatal_error === true || /^Pyodide already (?:exited|fatally failed)\b/.test(errorText(err));
592
+ }
593
+ var LIVE_FLUSH_MS = 50;
594
+ var LIVE_LINE_BURST = 10;
595
+ var LIVE_LINES_PER_SECOND = 100;
1051
596
  function createWorkerHost(adapter) {
1052
597
  let pyodide = null;
1053
- let initPromise = null;
1054
- let pytestPromise = null;
1055
- let httpPatchPromise = null;
598
+ let indexUrl = "";
599
+ let packageCacheDir;
1056
600
  let stdinBuffer = null;
1057
601
  let interruptBuffer = null;
1058
602
  let livePending = null;
1059
603
  let liveLastPost = 0;
604
+ let liveLineTokens = LIVE_LINE_BURST;
605
+ let liveTokensAt = 0;
606
+ let liveRequest = 0;
607
+ const packageProgress = {
608
+ messageCallback: (text) => adapter.post({ type: "packageNote", text, failed: false }),
609
+ errorCallback: (text) => adapter.post({ type: "packageNote", text, failed: true })
610
+ };
1060
611
  function flushLive() {
1061
612
  if (!livePending) {
1062
613
  return;
@@ -1064,7 +615,18 @@ function createWorkerHost(adapter) {
1064
615
  const payload = livePending;
1065
616
  livePending = null;
1066
617
  liveLastPost = Date.now();
1067
- adapter.post({ type: "display", payload });
618
+ adapter.post({ type: "display", requestId: liveRequest, payload });
619
+ }
620
+ function takeLineToken() {
621
+ const now = Date.now();
622
+ const earned = (now - liveTokensAt) * LIVE_LINES_PER_SECOND / 1e3;
623
+ liveLineTokens = Math.min(LIVE_LINE_BURST, liveLineTokens + earned);
624
+ liveTokensAt = now;
625
+ if (liveLineTokens < 1) {
626
+ return false;
627
+ }
628
+ liveLineTokens -= 1;
629
+ return true;
1068
630
  }
1069
631
  function emitDisplay(json) {
1070
632
  let payload;
@@ -1080,88 +642,68 @@ function createWorkerHost(adapter) {
1080
642
  flushLive();
1081
643
  livePending = { type: payload.type, text: payload.text };
1082
644
  }
1083
- if (Date.now() - liveLastPost >= LIVE_FLUSH_MS) {
645
+ if (payload.text.includes("\n") && takeLineToken() || Date.now() - liveLastPost >= LIVE_FLUSH_MS) {
1084
646
  flushLive();
1085
647
  }
1086
648
  return;
1087
649
  }
1088
650
  flushLive();
1089
- adapter.post({ type: "display", payload });
651
+ adapter.post({ type: "display", requestId: liveRequest, payload });
1090
652
  }
1091
- function enableTypeChecking(instance) {
1092
- try {
1093
- try {
1094
- instance.FS.mkdir(PLL_VENDOR_DIR);
1095
- } catch {
653
+ let stdinCarry = new Uint8Array(0);
654
+ function readStdin(buffer) {
655
+ if (stdinCarry.length === 0) {
656
+ if (!stdinBuffer) {
657
+ throw new Error(adapter.stdinUnavailableMessage);
1096
658
  }
1097
- for (const wheel of VENDORED_WHEELS) {
1098
- instance.FS.writeFile(`${PLL_VENDOR_DIR}/${wheel.name}`, decodeBase64(wheel.base64));
659
+ flushLive();
660
+ const got = waitForStdin(stdinBuffer, (request) => adapter.post({ type: "stdinRequest", request }));
661
+ if (got === "interrupted") {
662
+ throw Object.assign(new Error("interrupted"), { code: "EINTR" });
1099
663
  }
1100
- const enable = instance.globals.get("_pll_enable_type_checking");
1101
- try {
1102
- enable();
1103
- } finally {
1104
- enable.destroy?.();
664
+ if (got === null) {
665
+ return 0;
1105
666
  }
1106
- } catch {
667
+ stdinCarry = got;
1107
668
  }
669
+ const n = Math.min(buffer.length, stdinCarry.length);
670
+ buffer.set(stdinCarry.subarray(0, n));
671
+ stdinCarry = stdinCarry.subarray(n);
672
+ return n;
1108
673
  }
1109
- function readStdin() {
1110
- if (!stdinBuffer) {
1111
- throw new Error(adapter.stdinUnavailableMessage);
1112
- }
1113
- flushLive();
1114
- return waitForStdinLine(stdinBuffer, () => adapter.post({ type: "stdinRequest" }));
1115
- }
1116
- async function ensurePyodide(indexUrl) {
1117
- if (!initPromise) {
1118
- initPromise = (async () => {
1119
- const instance = await adapter.loadPyodide(indexUrl);
1120
- instance.setStdin({ stdin: readStdin, autoEOF: true });
1121
- if (interruptBuffer) {
1122
- instance.setInterruptBuffer(new Uint8Array(interruptBuffer));
1123
- }
1124
- instance.runPython(PYODIDE_BOOTSTRAP_PY);
1125
- instance.runPython(PLL_IMAGE_LIB_PY);
1126
- instance.runPython(PLL_TABLE_LIB_PY);
1127
- instance.runPython(PLL_REACTOR_LIB_PY);
1128
- instance.runPython(PLL_EXAMPLAR_LIB_PY);
1129
- instance.runPython(PYODIDE_INSTALL_PY);
1130
- enableTypeChecking(instance);
1131
- ensureWorkDir(instance.FS);
1132
- pyodide = instance;
1133
- return instance;
1134
- })();
674
+ const ensurePyodide = onceSuccessful(async () => {
675
+ const instance = await adapter.loadPyodide(indexUrl, packageCacheDir);
676
+ instance.setStdin({ read: readStdin });
677
+ if (interruptBuffer) {
678
+ instance.setInterruptBuffer(new Uint8Array(interruptBuffer));
1135
679
  }
1136
- return initPromise;
1137
- }
680
+ installPll(instance, { interruptBuffer });
681
+ pyodide = instance;
682
+ return instance;
683
+ });
1138
684
  function ready() {
1139
685
  if (!pyodide) {
1140
686
  throw new Error("Pyodide not initialized");
1141
687
  }
1142
688
  return pyodide;
1143
689
  }
1144
- function ensureHttpShim() {
1145
- if (!httpPatchPromise) {
1146
- const instance = ready();
1147
- httpPatchPromise = instance.loadPackage("pyodide-http").then(() => void instance.runPython(PYODIDE_HTTP_PATCH_PY));
1148
- }
1149
- return httpPatchPromise;
1150
- }
1151
- function ensurePytest() {
1152
- if (!pytestPromise) {
1153
- pytestPromise = ready().loadPackage("pytest");
1154
- }
1155
- return pytestPromise;
690
+ const loadHttpShim = onceSuccessful(() => ready().loadPackage("pyodide-http", packageProgress));
691
+ async function ensureHttpShim() {
692
+ await loadHttpShim();
693
+ ready().runPython(PYODIDE_HTTP_PATCH_PY.source, { filename: PYODIDE_HTTP_PATCH_PY.file });
1156
694
  }
695
+ const ensurePytest = onceSuccessful(() => ready().loadPackage("pytest", packageProgress));
1157
696
  function callPython(name, args) {
1158
697
  const fn = ready().globals.get(name);
1159
698
  try {
1160
- const proxy = fn(...args);
699
+ const value = fn(...args);
700
+ if (typeof value?.toJs !== "function") {
701
+ return nullForNone(value);
702
+ }
1161
703
  try {
1162
- return proxy.toJs({ dict_converter: Object.fromEntries });
704
+ return nullForNone(value.toJs({ dict_converter: Object.fromEntries }));
1163
705
  } finally {
1164
- proxy.destroy?.();
706
+ value.destroy?.();
1165
707
  }
1166
708
  } finally {
1167
709
  fn.destroy?.();
@@ -1172,215 +714,330 @@ function createWorkerHost(adapter) {
1172
714
  clearInterrupt(interruptBuffer);
1173
715
  }
1174
716
  }
1175
- function withLiveEmit(run) {
717
+ function withLiveEmit(requestId, run2) {
718
+ liveRequest = requestId;
1176
719
  ready().globals.set("_pll_live_emit", emitDisplay);
720
+ ready().globals.set("_pll_live_flush", flushLive);
1177
721
  livePending = null;
1178
722
  liveLastPost = 0;
723
+ liveLineTokens = LIVE_LINE_BURST;
724
+ liveTokensAt = Date.now();
1179
725
  try {
1180
- return run();
726
+ return run2();
1181
727
  } finally {
1182
728
  flushLive();
1183
- ready().runPython("_pll_live_emit = None");
729
+ ready().runPython("_pll_live_emit = None\n_pll_live_flush = None");
1184
730
  }
1185
731
  }
1186
- return async function handle2(data) {
1187
- try {
1188
- switch (data.type) {
1189
- case "init": {
1190
- stdinBuffer = data.stdinBuffer ?? null;
1191
- interruptBuffer = data.interruptBuffer ?? null;
1192
- await ensurePyodide(data.indexUrl);
1193
- adapter.post({ id: data.id, type: "ready" });
1194
- break;
1195
- }
1196
- case "runFile": {
1197
- dropPendingInterrupt();
1198
- const result = withLiveEmit(
1199
- () => callPython("_pll_run_file", [
1200
- data.code,
1201
- data.fileName,
1202
- data.sessionKey,
1203
- data.level ?? "raw"
1204
- ])
1205
- );
1206
- result.displays = [];
1207
- adapter.post({ id: data.id, type: "result", result });
1208
- break;
1209
- }
1210
- case "replEval": {
1211
- dropPendingInterrupt();
1212
- const result = callPython("_pll_repl_eval", [
1213
- data.code,
1214
- data.sessionKey,
1215
- data.level ?? "raw"
1216
- ]);
1217
- adapter.post({ id: data.id, type: "result", result });
1218
- break;
1219
- }
1220
- case "checkSyntax": {
1221
- const result = callPython("_pll_repl_check", [data.code]);
1222
- adapter.post({ id: data.id, type: "syntax", result });
1223
- break;
1224
- }
1225
- case "hasTests": {
1226
- const fn = ready().globals.get("_pll_has_tests");
1227
- try {
1228
- adapter.post({ id: data.id, type: "hasTests", result: Boolean(fn(data.code)) });
1229
- } finally {
1230
- fn.destroy?.();
1231
- }
1232
- break;
1233
- }
1234
- case "loadPackages": {
1235
- await ready().loadPackagesFromImports(data.code);
1236
- if (NETWORK_IMPORT_RE.test(data.code)) {
1237
- await ensureHttpShim();
1238
- }
1239
- adapter.post({ id: data.id, type: "packagesReady" });
1240
- break;
1241
- }
1242
- case "loadPytest": {
1243
- await ensurePytest();
1244
- adapter.post({ id: data.id, type: "pytestReady" });
1245
- break;
1246
- }
1247
- case "runTests": {
1248
- dropPendingInterrupt();
1249
- await ensurePytest();
1250
- const result = callPython("_pll_run_tests", [
1251
- data.code,
1252
- data.fileName,
1253
- data.level ?? "raw"
1254
- ]);
1255
- adapter.post({ id: data.id, type: "testResult", result });
1256
- break;
1257
- }
1258
- case "staticAnalyze": {
1259
- const result = callPython("_pll_static_analyze", [
1260
- data.code,
1261
- data.level,
1262
- data.fileName,
1263
- data.sessionKey ?? null
1264
- ]) ?? [];
1265
- adapter.post({ id: data.id, type: "static", result });
1266
- break;
1267
- }
1268
- case "reactorStep": {
1269
- const result = withLiveEmit(
1270
- () => callPython("_pll_reactor_step", [
1271
- data.reactorId,
1272
- data.event
1273
- ])
1274
- );
1275
- adapter.post({ id: data.id, type: "reactorFrame", result });
1276
- break;
1277
- }
1278
- case "reactorSeek": {
1279
- const result = callPython("_pll_reactor_seek", [
1280
- data.reactorId,
1281
- data.index
1282
- ]);
1283
- adapter.post({ id: data.id, type: "reactorFrame", result });
1284
- break;
1285
- }
1286
- case "reactorDispose": {
1287
- const fn = ready().globals.get("_pll_reactor_dispose");
1288
- try {
1289
- fn(data.reactorId);
1290
- } finally {
1291
- fn.destroy?.();
1292
- }
1293
- adapter.post({ id: data.id, type: "reactorDisposed" });
1294
- break;
1295
- }
1296
- case "examplarBuild": {
1297
- const result = callPython("_pll_examplar_build", [
1298
- data.sources
1299
- ]);
1300
- adapter.post({ id: data.id, type: "examplarBuilt", result });
1301
- break;
1302
- }
1303
- case "examplarRun": {
1304
- const result = callPython("_pll_examplar_run", [
1305
- data.testSource,
1306
- data.bundle
1307
- ]);
1308
- adapter.post({ id: data.id, type: "examplarRan", result });
1309
- break;
1310
- }
1311
- case "mountWorkspace": {
1312
- mountWorkspaceFiles(ready().FS, data.files);
1313
- adapter.post({ id: data.id, type: "workspaceReady" });
1314
- break;
1315
- }
1316
- case "collectWorkspace": {
1317
- const files = collectChangedWorkspaceFiles(ready().FS);
1318
- adapter.post({ id: data.id, type: "workspaceFiles", files });
1319
- break;
1320
- }
732
+ const requests = {
733
+ async init(data) {
734
+ stdinBuffer = data.stdinBuffer ?? null;
735
+ interruptBuffer = data.interruptBuffer ?? null;
736
+ indexUrl = data.indexUrl;
737
+ packageCacheDir = data.packageCacheDir;
738
+ await ensurePyodide();
739
+ return { type: "ready" };
740
+ },
741
+ runFile(data) {
742
+ const result = withLiveEmit(
743
+ data.id,
744
+ () => callPython("_pll_run_file", [
745
+ data.code,
746
+ data.fileName,
747
+ data.sessionKey,
748
+ data.level ?? DEFAULT_LEVEL,
749
+ data.withTests ?? false
750
+ ])
751
+ );
752
+ result.displays = [];
753
+ return { type: "result", result };
754
+ },
755
+ replEval(data) {
756
+ const result = callPython("_pll_repl_eval", [
757
+ data.code,
758
+ data.sessionKey,
759
+ data.level ?? DEFAULT_LEVEL
760
+ ]);
761
+ return { type: "result", result };
762
+ },
763
+ checkSyntax(data) {
764
+ const result = callPython("_pll_repl_check", [data.code, data.whole === true]);
765
+ return { type: "syntax", result };
766
+ },
767
+ hasTests(data) {
768
+ return { type: "hasTests", result: callPython("_pll_has_tests", [data.code]) };
769
+ },
770
+ async loadPackages(data) {
771
+ const siblings = data.siblings ?? [];
772
+ const found = callPython("_pll_package_imports", [
773
+ data.code,
774
+ JSON.stringify(siblings)
775
+ ]);
776
+ const modules = [...found.modules];
777
+ if ([data.code, ...siblings.map((s) => s.text)].some((text) => PANDAS_METHOD_RE.test(text))) {
778
+ modules.push("pandas");
779
+ }
780
+ if (modules.length > 0) {
781
+ await ready().loadPackagesFromImports(modules.map((name) => `import ${name}`).join("\n"), packageProgress);
1321
782
  }
783
+ if (found.network || modules.includes("pandas")) {
784
+ await ensureHttpShim();
785
+ }
786
+ return { type: "packagesReady" };
787
+ },
788
+ async loadPytest() {
789
+ await ensurePytest();
790
+ return { type: "pytestReady" };
791
+ },
792
+ staticAnalyze(data) {
793
+ const result = callPython("_pll_static_analyze", [
794
+ data.code,
795
+ data.level,
796
+ data.fileName,
797
+ data.sessionKey ?? null
798
+ ]) ?? [];
799
+ return { type: "static", result };
800
+ },
801
+ reactorStep(data) {
802
+ const result = withLiveEmit(
803
+ data.id,
804
+ () => callPython("_pll_reactor_step", [
805
+ data.reactorId,
806
+ data.event
807
+ ])
808
+ );
809
+ return { type: "reactorFrame", result };
810
+ },
811
+ reactorSeek(data) {
812
+ const result = callPython("_pll_reactor_seek", [
813
+ data.reactorId,
814
+ data.index
815
+ ]);
816
+ return { type: "reactorFrame", result };
817
+ },
818
+ reactorDispose(data) {
819
+ callPython("_pll_reactor_dispose", [data.reactorId]);
820
+ return { type: "reactorDisposed" };
821
+ },
822
+ endSession(data) {
823
+ callPython("_pll_end_session", [data.sessionKey]);
824
+ return { type: "sessionEnded" };
825
+ },
826
+ examplarBuild(data) {
827
+ const result = callPython("_pll_examplar_build", [
828
+ data.sources
829
+ ]);
830
+ return { type: "examplarBuilt", result };
831
+ },
832
+ examplarRun(data) {
833
+ const result = callPython("_pll_examplar_run", [
834
+ data.testSource,
835
+ data.bundle,
836
+ data.fileName
837
+ ]);
838
+ return { type: "examplarRan", result };
839
+ },
840
+ mountWorkspace(data) {
841
+ mountWorkspaceFiles(ready().FS, data.files);
842
+ callPython("_pll_note_file_levels", [JSON.stringify(levelsOf(data.files))]);
843
+ const leftOut = (data.leftOut ?? []).map((file) => ({ name: file.name, why: whyNotLoaded(file) }));
844
+ callPython("_pll_note_left_out", [JSON.stringify(leftOut)]);
845
+ return { type: "workspaceReady" };
846
+ },
847
+ collectWorkspace() {
848
+ return { type: "workspaceFiles", changes: collectChangedWorkspaceFiles(ready().FS) };
849
+ }
850
+ };
851
+ async function handleOne(data) {
852
+ if (data.type !== "init") {
853
+ dropPendingInterrupt();
854
+ }
855
+ try {
856
+ const request = requests[data.type];
857
+ adapter.post({ id: data.id, ...await request(data) });
1322
858
  } catch (err) {
1323
- adapter.post({
1324
- id: data.id,
1325
- type: "error",
1326
- message: err instanceof Error ? err.message : String(err)
1327
- });
859
+ adapter.post({ id: data.id, type: "error", message: errorText(err), kind: errorKind(err) });
1328
860
  }
861
+ }
862
+ let queue = Promise.resolve();
863
+ return function handle2(data) {
864
+ queue = queue.then(() => handleOne(data));
865
+ return queue;
1329
866
  };
1330
867
  }
868
+ function levelsOf(files) {
869
+ const levels = {};
870
+ const decoder = new TextDecoder();
871
+ for (const file of files) {
872
+ if (!file.name.endsWith(".py")) continue;
873
+ const text = typeof file.contents === "string" ? file.contents : decoder.decode(file.contents);
874
+ const problem = levelHeaderProblem(text);
875
+ levels[file.name] = [parseLevel(text), problem && { line: problem.line, message: problem.message }];
876
+ }
877
+ return levels;
878
+ }
1331
879
 
1332
880
  // src/desktop/syncHttp.ts
1333
- var import_node_child_process = require("node:child_process");
1334
- function syncHttpRequest(opts) {
1335
- const payload = JSON.stringify({
1336
- method: opts.method || "GET",
1337
- url: opts.url,
1338
- headers: opts.headers ?? {},
1339
- body: opts.body == null || opts.body === "" ? null : Buffer.from(
1340
- typeof opts.body === "string" ? opts.body : Buffer.from(opts.body)
1341
- ).toString("base64")
881
+ var import_node_worker_threads = require("node:worker_threads");
882
+ var DEFAULT_TIMEOUT_MS = 6e4;
883
+ var MAX_RESPONSE_BYTES = 256 * 1024 * 1024;
884
+ function syncHttpRequest(request) {
885
+ const thread = helperThread();
886
+ const id = ++lastId;
887
+ const timeoutMs = request.timeoutMs && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_TIMEOUT_MS;
888
+ Atomics.store(thread.signal, 0, 0);
889
+ thread.port.postMessage({
890
+ id,
891
+ method: request.method || "GET",
892
+ url: request.url,
893
+ headers: request.headers ?? {},
894
+ body: request.body == null || request.body === "" ? null : request.body,
895
+ timeoutMs,
896
+ limit: MAX_RESPONSE_BYTES
1342
897
  });
1343
- const script = `
1344
- const fs = require("node:fs");
1345
- const raw = fs.readFileSync(0, "utf8");
1346
- const req = JSON.parse(raw);
1347
- const init = { method: req.method, headers: req.headers };
1348
- if (req.body) init.body = Buffer.from(req.body, "base64");
1349
- fetch(req.url, init).then(async (r) => {
1350
- const hop = new Set(["transfer-encoding", "content-encoding", "connection", "keep-alive"]);
1351
- const headers = {};
1352
- r.headers.forEach((v, k) => {
1353
- if (!hop.has(k.toLowerCase())) headers[k] = v;
898
+ for (; ; ) {
899
+ Atomics.wait(thread.signal, 0, 0, 20);
900
+ const received = (0, import_node_worker_threads.receiveMessageOnPort)(thread.port);
901
+ if (received !== void 0) {
902
+ const reply = received.message;
903
+ if (reply.id === id) {
904
+ if (reply.error) {
905
+ throw named(reply.error.name, reply.error.message);
906
+ }
907
+ const body = reply.body ? new Uint8Array(reply.body) : new Uint8Array(0);
908
+ return { status: reply.status ?? 0, headers: reply.headers ?? {}, body };
909
+ }
910
+ Atomics.store(thread.signal, 0, 0);
911
+ continue;
912
+ }
913
+ if (request.interrupt && request.interrupt[0] === INTERRUPT_SIGINT) {
914
+ thread.port.postMessage({ cancel: id });
915
+ throw named("AbortError", "Stopped.");
916
+ }
917
+ }
918
+ }
919
+ function named(name, message) {
920
+ const error = new Error(message);
921
+ error.name = name;
922
+ return error;
923
+ }
924
+ var lastId = 0;
925
+ var helper = null;
926
+ function helperThread() {
927
+ if (helper !== null) {
928
+ return helper;
929
+ }
930
+ const { port1, port2 } = new import_node_worker_threads.MessageChannel();
931
+ const signal = new Int32Array(new SharedArrayBuffer(4));
932
+ const worker = new import_node_worker_threads.Worker(HELPER_SOURCE, {
933
+ eval: true,
934
+ workerData: { port: port2, signal },
935
+ transferList: [port2]
1354
936
  });
1355
- const raw = Buffer.from(await r.arrayBuffer());
1356
- headers["content-length"] = String(raw.length);
1357
- const body = raw.toString("base64");
1358
- process.stdout.write(JSON.stringify({ status: r.status, headers, body }));
1359
- }).catch((e) => {
1360
- process.stderr.write(String(e && e.stack ? e.stack : e));
1361
- process.exit(1);
1362
- });
1363
- `;
1364
- let out;
937
+ worker.unref();
938
+ helper = { port: port1, signal };
939
+ return helper;
940
+ }
941
+ var HELPER_SOURCE = `
942
+ const { workerData } = require("node:worker_threads");
943
+ const { port, signal } = workerData;
944
+ const running = new Map();
945
+ const hop = new Set(["transfer-encoding", "content-encoding", "connection", "keep-alive"]);
946
+
947
+ function reply(message, transfer) {
948
+ port.postMessage(message, transfer);
949
+ Atomics.store(signal, 0, 1);
950
+ Atomics.notify(signal, 0);
951
+ }
952
+
953
+ function reason(error) {
954
+ let cause = error && error.cause;
955
+ while (cause && cause.cause) cause = cause.cause;
956
+ const code = (cause && (cause.code || cause.message)) || (error && error.message) || String(error);
957
+ return String(code);
958
+ }
959
+
960
+ port.on("message", async (request) => {
961
+ if (request.cancel !== undefined) {
962
+ running.get(request.cancel)?.abort();
963
+ return;
964
+ }
965
+ const controller = new AbortController();
966
+ running.set(request.id, controller);
967
+ const timer = setTimeout(() => controller.abort("timeout"), request.timeoutMs);
968
+ const host = (() => { try { return new URL(request.url).host; } catch { return request.url; } })();
1365
969
  try {
1366
- out = (0, import_node_child_process.execFileSync)(process.execPath, ["-e", script], {
1367
- input: payload,
1368
- maxBuffer: 64 * 1024 * 1024,
1369
- windowsHide: true
970
+ const init = { method: request.method, headers: request.headers, signal: controller.signal, redirect: "follow" };
971
+ if (request.body !== null) init.body = request.body;
972
+ const response = await fetch(request.url, init);
973
+ const headers = {};
974
+ response.headers.forEach((value, key) => {
975
+ if (!hop.has(key.toLowerCase())) headers[key] = value;
1370
976
  });
1371
- } catch (err) {
1372
- const msg = err instanceof Error ? err.message : String(err);
1373
- throw new Error(`HTTP request failed: ${msg}`);
977
+ const chunks = [];
978
+ let size = 0;
979
+ if (response.body) {
980
+ for await (const chunk of response.body) {
981
+ size += chunk.byteLength;
982
+ if (size > request.limit) {
983
+ controller.abort("limit");
984
+ throw Object.assign(new Error("limit"), { pllLimit: true });
985
+ }
986
+ chunks.push(chunk);
987
+ }
988
+ }
989
+ const body = new Uint8Array(size);
990
+ let at = 0;
991
+ for (const chunk of chunks) {
992
+ body.set(chunk, at);
993
+ at += chunk.byteLength;
994
+ }
995
+ headers["content-length"] = String(size);
996
+ reply({ id: request.id, status: response.status, headers, body: body.buffer }, [body.buffer]);
997
+ } catch (error) {
998
+ let failure;
999
+ if (error && error.pllLimit) {
1000
+ failure = { name: "NetworkError", message: "the answer from " + host + " is larger than " + Math.round(request.limit / 1048576) + " MB" };
1001
+ } else if (controller.signal.aborted && controller.signal.reason === "timeout") {
1002
+ const seconds = Math.round(request.timeoutMs / 100) / 10;
1003
+ failure = { name: "TimeoutError", message: host + " did not answer within " + seconds + (seconds === 1 ? " second" : " seconds") };
1004
+ } else if (controller.signal.aborted) {
1005
+ failure = { name: "AbortError", message: "Stopped." };
1006
+ } else {
1007
+ failure = { name: "NetworkError", message: "could not connect to " + host + " (" + reason(error) + ")" };
1008
+ }
1009
+ reply({ id: request.id, error: failure });
1010
+ } finally {
1011
+ clearTimeout(timer);
1012
+ running.delete(request.id);
1374
1013
  }
1375
- const parsed = JSON.parse(out.toString("utf8"));
1376
- return {
1377
- status: parsed.status,
1378
- headers: parsed.headers ?? {},
1379
- body: new Uint8Array(Buffer.from(parsed.body ?? "", "base64"))
1380
- };
1381
- }
1014
+ });
1015
+ `;
1382
1016
 
1383
1017
  // src/desktop/xhrPolyfill.ts
1018
+ function decodeUserDefined(bytes) {
1019
+ const CHUNK = 32768;
1020
+ let out = "";
1021
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1022
+ const codes = Array.from(bytes.subarray(i, i + CHUNK), (b) => b < 128 ? b : 63232 + b);
1023
+ out += String.fromCharCode(...codes);
1024
+ }
1025
+ return out;
1026
+ }
1027
+ function charsetOf(mime) {
1028
+ const found = mime ? /;\s*charset=("?)([^";]+)\1/i.exec(mime) : null;
1029
+ return found ? found[2].trim().toLowerCase() : null;
1030
+ }
1031
+ function decodeText(bytes, charset) {
1032
+ if (charset === "x-user-defined") {
1033
+ return decodeUserDefined(bytes);
1034
+ }
1035
+ try {
1036
+ return new TextDecoder(charset ?? "utf-8").decode(bytes);
1037
+ } catch {
1038
+ return new TextDecoder("utf-8").decode(bytes);
1039
+ }
1040
+ }
1384
1041
  function installNodeXHR() {
1385
1042
  const g = globalThis;
1386
1043
  if (typeof g.crossOriginIsolated === "undefined") {
@@ -1395,9 +1052,12 @@ function installNodeXHR() {
1395
1052
  static HEADERS_RECEIVED = 2;
1396
1053
  static LOADING = 3;
1397
1054
  static DONE = 4;
1055
+ /** Says this is the desktop's, where a failed request is not about CORS. */
1056
+ static pllNode = true;
1398
1057
  method = "GET";
1399
1058
  url = "";
1400
1059
  async = true;
1060
+ /** Milliseconds, as a browser's; 0 for the default limit. */
1401
1061
  timeout = 0;
1402
1062
  responseType = "";
1403
1063
  withCredentials = false;
@@ -1406,8 +1066,11 @@ function installNodeXHR() {
1406
1066
  response = null;
1407
1067
  responseText = "";
1408
1068
  readyState = 0;
1069
+ /** The interrupt buffer, set by PLL's Python so a Stop ends the wait. */
1070
+ pllInterrupt = null;
1409
1071
  reqHeaders = {};
1410
1072
  resHeaders = {};
1073
+ mimeOverride = null;
1411
1074
  open(method, url, async = true) {
1412
1075
  this.method = method;
1413
1076
  this.url = url;
@@ -1417,7 +1080,8 @@ function installNodeXHR() {
1417
1080
  setRequestHeader(name, value) {
1418
1081
  this.reqHeaders[name] = value;
1419
1082
  }
1420
- overrideMimeType(_mime) {
1083
+ overrideMimeType(mime) {
1084
+ this.mimeOverride = mime;
1421
1085
  }
1422
1086
  send(body) {
1423
1087
  let payload = null;
@@ -1434,7 +1098,9 @@ function installNodeXHR() {
1434
1098
  method: this.method,
1435
1099
  url: this.url,
1436
1100
  headers: this.reqHeaders,
1437
- body: payload
1101
+ body: payload,
1102
+ timeoutMs: this.timeout,
1103
+ interrupt: this.pllInterrupt
1438
1104
  });
1439
1105
  this.status = result.status;
1440
1106
  this.statusText = result.status >= 200 && result.status < 300 ? "OK" : "";
@@ -1446,7 +1112,8 @@ function installNodeXHR() {
1446
1112
  this.response = copy.buffer;
1447
1113
  this.responseText = "";
1448
1114
  } else {
1449
- const text = new TextDecoder("latin1").decode(result.body);
1115
+ const charset = charsetOf(this.mimeOverride) ?? charsetOf(this.getResponseHeader("content-type"));
1116
+ const text = decodeText(result.body, charset);
1450
1117
  this.response = text;
1451
1118
  this.responseText = text;
1452
1119
  }
@@ -1468,10 +1135,10 @@ function installNodeXHR() {
1468
1135
  }
1469
1136
 
1470
1137
  // src/desktop/pyodideWorker.ts
1471
- if (!import_node_worker_threads.parentPort) {
1138
+ if (!import_node_worker_threads2.parentPort) {
1472
1139
  throw new Error("desktop pyodide worker must be started as a worker_thread");
1473
1140
  }
1474
- var port = import_node_worker_threads.parentPort;
1141
+ var port = import_node_worker_threads2.parentPort;
1475
1142
  function ensureStdioFds() {
1476
1143
  const streams = [
1477
1144
  [process.stdin, 0],
@@ -1487,12 +1154,14 @@ function ensureStdioFds() {
1487
1154
  var handle = createWorkerHost({
1488
1155
  post: (msg) => port.postMessage(msg),
1489
1156
  stdinUnavailableMessage: "input() is unavailable (SharedArrayBuffer was not provided).",
1490
- async loadPyodide(indexUrl) {
1157
+ async loadPyodide(indexUrl, packageCacheDir) {
1491
1158
  ensureStdioFds();
1492
1159
  installNodeXHR();
1493
1160
  const { loadPyodide } = await import("pyodide");
1494
- return await loadPyodide({ indexURL: indexUrl });
1161
+ return await loadPyodide({
1162
+ indexURL: indexUrl,
1163
+ ...packageCacheDir ? { packageCacheDir } : {}
1164
+ });
1495
1165
  }
1496
1166
  });
1497
1167
  port.on("message", (data) => void handle(data));
1498
- //# sourceMappingURL=worker.cjs.map