pll-python 0.6.0 → 0.6.1

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 (3) hide show
  1. package/cli.cjs +1 -1
  2. package/package.json +1 -1
  3. package/worker.cjs +5 -5
package/worker.cjs CHANGED
@@ -176,7 +176,7 @@ function collectChangedWorkspaceFiles(FS) {
176
176
  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 pyodide/install.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 Said so by `_pll_bool_as_number_note` (explain/typeCheck.py).\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';
177
177
 
178
178
  // src/python/bootstrap/errorInfo.py
179
- 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\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_failed_instruction(tb):\n """What the student\'s code was doing when it failed, as the name of the\n instruction - `CALL` when a function it called raised with no frame of\n its own (a built-in), `GET_ITER` when a `for` started on a value - or\n None when the innermost frame is not theirs. The two can fail at the\n same place: `for n in max(xs)` and `max(5)` both point at the call."""\n if tb is None:\n return None\n while tb.tb_next is not None:\n tb = tb.tb_next\n code = tb.tb_frame.f_code\n if not _pll_is_students(code.co_filename):\n return None\n try:\n import dis as _pll_dis\n\n for instruction in _pll_dis.get_instructions(code):\n if instruction.offset == tb.tb_lasti:\n return instruction.opname\n except Exception:\n pass\n return None\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, level=None):\n """`exc` described, and explained: the `error` of a result.\n\n The one place an exception becomes a result, for a file run, a prompt\n line, a test and a reactor alike. `run` is `(filename, code)` for what\n was run, which the lines of its frames are read from (see\n `_pll_source_of`), and `level` its level.\n\n - `finding`: what the student is told, and where (`explain/finding.py`) -\n all the host reads, with `errorType` and `message`, Python\'s own words.\n - `frames`: outermost first, each `{file, line, column, function, user,\n 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 - `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 instruction = _pll_failed_instruction(exc.__traceback__)\n if instruction is not None:\n facts["instruction"] = instruction\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 error = {\n "errorType": type(exc).__name__,\n "message": _pll_displayed_message(exc),\n "fileName": where["file"],\n "lineNumber": where["line"],\n "column": where["column"],\n "text": where.get("text"),\n "frames": frames,\n "facts": facts,\n }\n try:\n error["finding"] = _pll_explain_error(error, run, level)\n except Exception:\n # An explanation that fails leaves Python\'s own words: the error\n # itself is reported either way.\n error["finding"] = _pll_unexplained(error, run)\n return error\n';
179
+ 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 - `_Ellipse`, 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\n#: How Python words a call of a value that is not a function.\n_PLL_NOT_CALLABLE_RE = _pll_src_re.compile(r"^\'([\\w.]+)\' object is not callable")\n\n\ndef _pll_enrich_not_callable(exc, run):\n """Record what was called, for `\'int\' object is not callable`: the name\n as written, how many arguments it was given, and whether it hides one\n of Python\'s own functions (`called`).\n\n The message names only the type. The name counts only when its call is\n the one that failed, in the student\'s own frame, and its value there is\n of that type: a built-in raises the same error at its own call when it\n is given the value, as `list(map(5, xs))` is.\n """\n found = _PLL_NOT_CALLABLE_RE.match(str(exc)) if type(exc) is TypeError else None\n if found is None or not str(_pll_failed_instruction(exc.__traceback__) or "").startswith("CALL"):\n return\n tb = exc.__traceback__\n while tb.tb_next is not None:\n tb = tb.tb_next\n frame = tb.tb_frame\n # The failing instruction spans the call, and no two calls end at the\n # same `)`.\n where = _tb_mod.extract_tb(tb)[-1]\n try:\n with _pll_without_syntax_warnings():\n tree = _ast.parse(_pll_source_of(frame.f_code, run))\n except (SyntaxError, ValueError):\n return\n call = next(\n (\n node\n for node in _ast.walk(tree)\n if isinstance(node, _ast.Call)\n and (node.end_lineno, node.end_col_offset) == (where.end_lineno, where.end_colno)\n ),\n None,\n )\n if call is None:\n return\n attributes, called = [], call.func\n while isinstance(called, _ast.Attribute):\n attributes.insert(0, called.attr)\n called = called.value\n if not isinstance(called, _ast.Name):\n return\n missing = object()\n value = missing\n for scope in (frame.f_locals, frame.f_globals, frame.f_builtins):\n value = scope.get(called.id, missing)\n if value is not missing:\n break\n for attribute in attributes:\n # As stored: reading it would run a property\'s code a second time.\n value = _pll_inspect.getattr_static(value, attribute, missing)\n if value is missing or type(value).__name__ != found.group(1).rsplit(".", 1)[-1]:\n return\n name = ".".join([called.id] + attributes)\n _pll_add_facts(\n exc,\n called={\n "name": name,\n "arguments": len(call.args) + len(call.keywords),\n "hides": callable(frame.f_builtins.get(name)),\n },\n )\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\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_failed_instruction(tb):\n """What the student\'s code was doing when it failed, as the name of the\n instruction - `CALL` when a function it called raised with no frame of\n its own (a built-in), `GET_ITER` when a `for` started on a value - or\n None when the innermost frame is not theirs. The two can fail at the\n same place: `for n in max(xs)` and `max(5)` both point at the call."""\n if tb is None:\n return None\n while tb.tb_next is not None:\n tb = tb.tb_next\n code = tb.tb_frame.f_code\n if not _pll_is_students(code.co_filename):\n return None\n try:\n import dis as _pll_dis\n\n for instruction in _pll_dis.get_instructions(code):\n if instruction.offset == tb.tb_lasti:\n return instruction.opname\n except Exception:\n pass\n return None\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, level=None):\n """`exc` described, and explained: the `error` of a result.\n\n The one place an exception becomes a result, for a file run, a prompt\n line, a test and a reactor alike. `run` is `(filename, code)` for what\n was run, which the lines of its frames are read from (see\n `_pll_source_of`), and `level` its level.\n\n - `finding`: what the student is told, and where (`explain/finding.py`) -\n all the host reads, with `errorType` and `message`, Python\'s own words.\n - `frames`: outermost first, each `{file, line, column, function, user,\n 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 - `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, the name a value that is not a\n function was called by - 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_not_callable(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 instruction = _pll_failed_instruction(exc.__traceback__)\n if instruction is not None:\n facts["instruction"] = instruction\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 error = {\n "errorType": type(exc).__name__,\n "message": _pll_displayed_message(exc),\n "fileName": where["file"],\n "lineNumber": where["line"],\n "column": where["column"],\n "text": where.get("text"),\n "frames": frames,\n "facts": facts,\n }\n try:\n error["finding"] = _pll_explain_error(error, run, level)\n except Exception:\n # An explanation that fails leaves Python\'s own words: the error\n # itself is reported either way.\n error["finding"] = _pll_unexplained(error, run)\n return error\n';
180
180
 
181
181
  // src/python/bootstrap/codeFacts.py
182
182
  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 # `type Animal = Boa | Armadillo` is an alias, and the union its value.\n if isinstance(obj, getattr(_pll_typing, "TypeAliasType", ())):\n obj = obj.__value__\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.Constant) and isinstance(annotation.value, str):\n try:\n annotation = _ast.parse(annotation.value, mode="eval").body\n except SyntaxError:\n return 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_pattern_class(name, frame, lineno, tree, source):\n """For a `case` pattern\'s class nothing defines - `case Aardvark(b):` -\n what the `match`\'s subject can be, when it is a parameter annotated\n with a union, and the classes the program has; or None when `name` is\n no such class."""\n node = _pll_function_node(tree, frame.f_code, lineno) if frame.f_code.co_name != "<module>" else None\n for match in _ast.walk(node or tree):\n if not isinstance(match, _ast.Match):\n continue\n if not any(\n isinstance(p, _ast.MatchClass) and isinstance(p.cls, _ast.Name) and p.cls.id == name and p.cls.lineno == lineno\n for case in match.cases\n for p in _ast.walk(case.pattern)\n ):\n continue\n subject = match.subject\n annotation = None\n if node is not None and 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 written = annotation.value.strip() if isinstance(annotation, _ast.Constant) else _pll_dotted(annotation)\n return {\n "name": name,\n "subject": _ast.get_source_segment(source, subject),\n # The union\'s own name, when the annotation is one: `Animal`.\n "union": written if members and written is not None and written.isidentifier() else None,\n "members": members if members and len(members) > 1 else None,\n "classes": sorted(\n n for n, v in frame.f_globals.items() if isinstance(v, type) and _pll_is_students_class(v)\n ),\n }\n return None\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`; for a `case` pattern\'s\n class nothing defines, `pattern_class`; and for a function annotated to\n 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 if type(exc) is NameError and isinstance(exc.name, str):\n pattern = _pll_pattern_class(exc.name, frame, lineno, tree, source)\n if pattern is not None:\n facts["pattern_class"] = pattern\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';
@@ -212,16 +212,16 @@ var undefinedNames_default = '# Names a program reads where they have no value:
212
212
  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_findings(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';
213
213
 
214
214
  // src/python/explain/finding.py
215
- var finding_default = '# From an exception to a finding: what the student is told about an error,\n# and where. `_pll_error_info` (errorInfo.py) describes the exception; the\n# rules registered here word it, each for the errors it explains, tried in\n# the order they are registered.\n\n#: `(types, explain)` in the order they are tried; see `_pll_explains`.\n_PLL_EXPLAINERS = []\n\n#: The errors Python places itself: the position is the error\'s, not a frame\'s.\n_PLL_PLACED_BY_PYTHON = ("SyntaxError", "IndentationError", "TabError")\n\n\ndef _pll_explains(*types):\n """Register a rule for errors of `types`: a function of the error\'s\n `_PllError`, giving `(id, headline, how_to_fix)` - or a dict of more of\n the finding\'s fields - or None to leave the error to the rules after it."""\n\n def register(explain):\n _PLL_EXPLAINERS.append((types, explain))\n return explain\n\n return register\n\n\nclass _PllError:\n """An error as the rules read it: what `_pll_error_info` made of it, the\n run it happened in, and where a finding about it goes."""\n\n def __init__(self, info, run, level):\n self.info = info\n self.type = info["errorType"]\n self.message = info["message"]\n self.facts = info["facts"]\n self.frames = info["frames"]\n self.name = self.facts.get("name")\n self.run = run\n self.level = level\n self.file, self.line, self.column, self.text = self._place()\n\n def _place(self):\n """Where a finding about the error is: a syntax error where Python\n says it is, and anything else at the innermost frame that runs the\n student\'s code - in whichever of their files that is.\n\n With no frame of the student\'s, the innermost one is PLL\'s or a\n library\'s, and its line means nothing in their file, so there is\n no line.\n """\n info = self.info\n run_file = self.run[0] if self.run else None\n if self.type in _PLL_PLACED_BY_PYTHON or not self.frames:\n file = info["fileName"] if info["fileName"] is not None else run_file\n text = info["text"]\n return file, info["lineNumber"], info["column"], text if text is not None else self._line_of_run(\n file, info["lineNumber"]\n )\n frame = self.user_frame\n if frame is None:\n return run_file, None, None, None\n # A caret is about the frame that raised; only there is it theirs.\n column = frame["column"] if frame is self.frames[-1] else None\n return frame["file"], frame["line"], column, self.frame_text(frame)\n\n @property\n def user_frames(self):\n """The frames running the student\'s code, outermost first."""\n return [frame for frame in self.frames if frame["user"]]\n\n @property\n def user_frame(self):\n """The student\'s own frame that failed - or, when a library raised, called it."""\n frames = self.user_frames\n return frames[-1] if frames else None\n\n def frame_text(self, frame):\n """The student\'s line at `frame`: Python\'s, or the run\'s own code."""\n if frame is None:\n return None\n return frame["text"] if frame["text"] is not None else self._line_of_run(frame["file"], frame["line"])\n\n @property\n def in_run_file(self):\n """Whether the finding is in the file that was run."""\n return self.run is not None and self.file == self.run[0]\n\n @property\n def source(self):\n """The run\'s code, when the finding is in it, or ""."""\n return self.run[1] if self.in_run_file else ""\n\n def _line_of_run(self, file, line):\n """A line of the code that was run - a prompt line has no file to\n read back - and only of it: another file\'s line number means\n nothing here."""\n if self.run is None or file != self.run[0] or not line:\n return None\n lines = _pll_src_re.split(r"\\r?\\n", self.run[1])\n return lines[line - 1] if 0 < line <= len(lines) else None\n\n\ndef _pll_explain_error(info, run, level):\n """The finding for the error `info` describes: from the first rule that\n explains it, or the catch-all - never None, so no student is shown a\n traceback through PLL\'s own internals."""\n error = _PllError(info, run, level)\n for types, explain in _PLL_EXPLAINERS:\n if error.type not in types:\n continue\n said = explain(error)\n if said is None:\n continue\n if isinstance(said, tuple):\n said = dict(zip(("id", "headline", "howToFix"), said))\n return _pll_runtime_finding(error, **said)\n return _pll_catch_all(error)\n\n\ndef _pll_catch_all(e):\n """The last resort: any error as a finding.\n\n It says nothing new. It puts the error in the student\'s own code and\n Python\'s message where they will read it, rather than at the end of a\n traceback through PLL\'s machinery or dozens of lines of pandas - the\n message a student needs is usually already clear (`No column named\n \'rider\' (have: month, riders)`). Rules that know an error well win.\n """\n # A table\'s rows are read from the source of the file that was run.\n row = _pll_row_line(e.source, e.line, e.message) if e.in_run_file else None\n return _pll_runtime_finding(\n e,\n id="runtime-error",\n headline=_pll_headline_for(e),\n # Deliberately empty. A generic error has no generic remedy, and\n # inventing one would be worse than the message itself.\n howToFix=[],\n lineNumber=row if row is not None else e.line,\n )\n\n\ndef _pll_headline_for(e):\n """The message as a sentence a student can read - or, a message the\n student raised themselves, as they wrote it."""\n text = e.message.strip()\n # A Stop, which the student asked for: not something their code did\n # wrong. A `KeyboardInterrupt` the program raised itself is its error.\n if e.type == "KeyboardInterrupt" and e.facts.get("stop") is True:\n return "The program was stopped."\n if not text:\n return "%s while running your program." % e.type\n innermost = e.frames[-1] if e.frames else None\n theirs = bool(innermost and innermost["user"] and _pll_src_re.match(r"\\s*raise\\b", innermost["text"] or ""))\n finish = (lambda sentence: sentence) if theirs else _pll_punctuated\n if e.type == "KeyError":\n # `str(KeyError(x))` is the *repr* of x, so a message written as a\n # sentence arrives wrapped in quotes: unwrap that one. A plain\n # missing key arrives the same way (`\'rider\'`) but is not a\n # sentence, and is left exactly as Python wrote it.\n unquoted = _pll_src_re.fullmatch(r"\'([\\s\\S]*)\'", text) or _pll_src_re.fullmatch(r\'"([\\s\\S]*)"\', text)\n if unquoted is None or not _pll_src_re.search(r"\\s", unquoted.group(1)):\n return text\n return finish(unquoted.group(1))\n return finish(text)\n\n\ndef _pll_row_line(source, call_line, message):\n """The line of the row a `table(...)` error is about, when it can be found.\n\n The error is raised inside the library, so the student\'s innermost\n frame is the `table(` line. The message names the row by position ("the\n 2nd row, ..."), and for rows written out in place that row has a line\n of its own - the one worth pointing at. Rows passed as a variable have\n none, and the `table(` line is kept.\n """\n ordinal = _pll_src_re.search(r"\\bthe (\\d+)(?:st|nd|rd|th) row\\b", message)\n if call_line is None or ordinal is None:\n return None\n index = int(ordinal.group(1)) - 1\n lines = _pll_src_re.split(r"\\r?\\n", source)\n if not 1 <= call_line <= len(lines):\n return None\n call = _pll_src_re.search(r"\\b(?:table|Table)\\s*\\(", lines[call_line - 1])\n if call is None:\n return None\n text = "\\n".join(lines)\n # Walk the call\'s brackets from just after `table(`, skipping strings\n # and comments, so a `,` or `]` inside one does not count.\n i = sum(len(line) + 1 for line in lines[: call_line - 1]) + call.end()\n depth, argument, row, rows_open, awaiting_row = 1, 0, -1, False, False\n while i < len(text) and depth > 0:\n c = text[i]\n if c in "\\"\'":\n quote = c * 3 if text.startswith(c * 3, i) else c\n i += len(quote)\n while i < len(text) and not text.startswith(quote, i):\n i += 2 if text[i] == "\\\\" else 1\n i += len(quote)\n continue\n if c == "#":\n while i < len(text) and text[i] != "\\n":\n i += 1\n continue\n if c.isspace():\n i += 1\n continue\n # The first character of a row: where it starts.\n if awaiting_row and c != "]":\n row += 1\n if row == index:\n return text.count("\\n", 0, i) + 1\n awaiting_row = False\n if c in "([{":\n depth += 1\n if depth == 2 and argument == 1 and c == "[" and not rows_open:\n rows_open = awaiting_row = True\n elif c in ")]}":\n depth -= 1\n if rows_open and depth == 1:\n # The rows list closed without reaching the row.\n return None\n elif c == ",":\n if depth == 1:\n argument += 1\n if argument > 1:\n return None\n elif depth == 2 and rows_open:\n awaiting_row = True\n elif depth == 1 and argument == 1 and not rows_open:\n # The rows argument is not a list written out here.\n return None\n i += 1\n return None\n\n\n@_pll_explains("ChecksFailed")\ndef _pll_explain_checks_failed(e):\n """One of the student\'s files that another imports, refused because it\n did not pass its own level\'s checks (`imports.py`). Reported at the\n `import`, which is where the program stopped, with each problem in the\n file worded as a run of that file words it."""\n checks = e.facts.get("checks")\n if checks is None:\n return None\n file, problem = checks["file"], checks["header_problem"]\n if problem is not None:\n problems = ["Line %s: %s" % (problem["line"], problem["message"])]\n headline = "`%s` was not imported: there is a problem with its `#level` line." % file\n else:\n problems = ["Line %s: %s" % (f["lineNumber"], f["headline"]) for f in checks["findings"]]\n count = "a problem" if len(problems) == 1 else "%d problems" % len(problems)\n headline = "`%s` was not imported: the checks of `#level %s` found %s in it." % (file, checks["level"], count)\n return {\n "id": "checks-failed",\n "errorType": "ChecksFailed",\n "headline": headline,\n "howToFix": problems + ["Run `%s` itself to see %s where it is." % (file, "it" if len(problems) == 1 else "each one")],\n }\n\n\ndef _pll_unexplained(info, run):\n """A finding in Python\'s own words, where the error is: for an error\n whose explanation failed."""\n message = info["message"].strip()\n return {\n "id": "runtime-error",\n "errorType": info["errorType"],\n "headline": message or "%s while running your program." % info["errorType"],\n "howToFix": [],\n "fileName": info["fileName"] if info["fileName"] is not None else (run[0] if run else None),\n "lineNumber": info["lineNumber"],\n "column": info["column"],\n "nameToken": info["facts"].get("name"),\n "severity": "error",\n "origin": "runtime",\n }\n\n\ndef _pll_runtime_finding(error, **fields):\n """A runtime finding about `error`, given what it says differently from\n the error: what Python called it, and where it is, are the error\'s own."""\n finding = {\n "errorType": error.type,\n "fileName": error.file,\n "lineNumber": error.line,\n "column": error.column,\n "nameToken": error.name,\n "severity": "error",\n "origin": "runtime",\n }\n finding.update(fields)\n return finding\n\n\ndef _pll_punctuated(text):\n """The text as a sentence: trimmed, and ending in a full stop if it had none."""\n trimmed = text.strip()\n return trimmed if trimmed.endswith((".", "!", "?")) else trimmed + "."\n\n\n# What the explanations know about PLL\'s own library that its definitions\n# do not say: the functions that call one of the student\'s, what each\n# passes it, and what to add to an arity error about one.\n\n#: The library functions that call a function the student passes them, and\n#: what they call it with. `each` and `param` are for the table methods,\n#: whose function decides about one row or value at a time. The distinction\n#: that matters is `transform_column`, which passes one *value* from a\n#: column, against `filter` and `add_column`, which pass a whole row.\n_PLL_FUNCTION_TAKERS = {\n "filter": {\n "calls": "`filter` calls your function with one row at a time",\n "each": "each row",\n "param": "r: dict",\n "keeps": "keeps the rows where it returns `True`",\n },\n "transform_column": {\n "calls": "`transform_column` calls your function with one *value* from the column, not a row",\n "each": "each value in the column",\n "param": "v",\n "keeps": "puts what it returns in place of the value",\n },\n "add_column": {\n "calls": "`add_column` calls your function with one row at a time",\n "each": "each row",\n "param": "r: dict",\n "keeps": "puts what it returns in the new column",\n },\n "animate": {"calls": "`animate` calls your function with the tick count, a number"},\n "big_bang": {"calls": "`big_bang` calls your handlers with the state"},\n}\n\n#: A reactor\'s handlers, which are given a function by keyword: `to_draw=draw`.\n_PLL_HANDLER_KEYWORDS = ("to_draw", "on_tick", "stop_when", "on_key", "on_mouse", "on_receive")\n\n#: Extra lines for an arity error on a library function, where knowing the\n#: parameter names is not enough. `table` is the one that matters: the shape\n#: of its two arguments - names first, then every row inside one more list -\n#: is what goes wrong, and "takes 2 arguments" does not say it.\n_PLL_LIBRARY_HINTS = {\n "table": [\n \'The column names come first, as a list of strings, then all the rows inside one more list:\'\n \' `table(["month", "riders"], [["Jan", 1], ["Feb", 2]])`.\'\n ],\n "table_from_columns": [\n \'`table_from_columns` takes one dictionary: `table_from_columns({"month": [...], "riders": [...]})`.\'\n ],\n}\n\ndef _pll_table_method(name):\n """Whether `name` is a method of a table, which is called on one:\n `people.order_by(...)`. Read off `Table` when asked: the table library\n loads after this file."""\n table = globals().get("Table")\n return isinstance(table, type) and not name.startswith("_") and callable(vars(table).get(name))\n\n\ndef _pll_library_caller(frames):\n """The library function that called the student\'s function, if one did -\n "reactor" for any frame in the reactor library, whichever of its methods\n was driving. Its frames are PLL\'s, not the student\'s, but knowing which\n one it was changes the advice completely."""\n for frame in frames:\n if frame["file"] == "<pll:reactor>":\n return "reactor"\n if frame["file"] in ("<pll:table>", "<pll:image>") and frame["function"] in _PLL_FUNCTION_TAKERS:\n return frame["function"]\n return None\n\n\n#: Plain-language words for the types a beginner course uses, without an\n#: article: the one table every explanation uses, so no two of them can name\n#: a type differently.\n_PLL_TYPE_WORDS = {\n "int": "whole number",\n "float": "number",\n "complex": "complex number",\n "str": "string",\n "bool": "`True` or `False`",\n "bytes": "bytes",\n "list": "list",\n "dict": "dictionary",\n "set": "set",\n "tuple": "tuple",\n "range": "range",\n "function": "function",\n "None": "`None`",\n "NoneType": "`None`",\n}\n\n\ndef _pll_type_words(type_name, article=False):\n """The words for a type: "whole number" - or, with `article`, "a whole\n number". None for a type with no plain name, such as a class the\n student wrote. Phrases that are not a noun ("`True` or `False`",\n "bytes") never take an article."""\n words = _PLL_TYPE_WORDS.get(type_name)\n if words is None or not article or words.startswith("`") or words == "bytes":\n return words\n return "a " + words\n\n\ndef _pll_plural(count, noun):\n """"1 argument", "2 arguments"."""\n return "%d %s%s" % (count, noun, "" if count == 1 else "s")\n';
215
+ var finding_default = '# From an exception to a finding: what the student is told about an error,\n# and where. `_pll_error_info` (errorInfo.py) describes the exception; the\n# rules registered here word it, each for the errors it explains, tried in\n# the order they are registered.\n\n#: `(types, explain)` in the order they are tried; see `_pll_explains`.\n_PLL_EXPLAINERS = []\n\n#: The errors Python places itself: the position is the error\'s, not a frame\'s.\n_PLL_PLACED_BY_PYTHON = ("SyntaxError", "IndentationError", "TabError")\n\n\ndef _pll_explains(*types):\n """Register a rule for errors of `types`: a function of the error\'s\n `_PllError`, giving `(id, headline, how_to_fix)` - or a dict of more of\n the finding\'s fields - or None to leave the error to the rules after it."""\n\n def register(explain):\n _PLL_EXPLAINERS.append((types, explain))\n return explain\n\n return register\n\n\nclass _PllError:\n """An error as the rules read it: what `_pll_error_info` made of it, the\n run it happened in, and where a finding about it goes."""\n\n def __init__(self, info, run, level):\n self.info = info\n self.type = info["errorType"]\n self.message = info["message"]\n self.facts = info["facts"]\n self.frames = info["frames"]\n self.name = self.facts.get("name")\n self.run = run\n self.level = level\n self.file, self.line, self.column, self.text = self._place()\n\n def _place(self):\n """Where a finding about the error is: a syntax error where Python\n says it is, and anything else at the innermost frame that runs the\n student\'s code - in whichever of their files that is.\n\n With no frame of the student\'s, the innermost one is PLL\'s or a\n library\'s, and its line means nothing in their file, so there is\n no line.\n """\n info = self.info\n run_file = self.run[0] if self.run else None\n if self.type in _PLL_PLACED_BY_PYTHON or not self.frames:\n file = info["fileName"] if info["fileName"] is not None else run_file\n text = info["text"]\n return file, info["lineNumber"], info["column"], text if text is not None else self._line_of_run(\n file, info["lineNumber"]\n )\n frame = self.user_frame\n if frame is None:\n return run_file, None, None, None\n # A caret is about the frame that raised; only there is it theirs.\n column = frame["column"] if frame is self.frames[-1] else None\n return frame["file"], frame["line"], column, self.frame_text(frame)\n\n @property\n def user_frames(self):\n """The frames running the student\'s code, outermost first."""\n return [frame for frame in self.frames if frame["user"]]\n\n @property\n def user_frame(self):\n """The student\'s own frame that failed - or, when a library raised, called it."""\n frames = self.user_frames\n return frames[-1] if frames else None\n\n def frame_text(self, frame):\n """The student\'s line at `frame`: Python\'s, or the run\'s own code."""\n if frame is None:\n return None\n return frame["text"] if frame["text"] is not None else self._line_of_run(frame["file"], frame["line"])\n\n @property\n def in_run_file(self):\n """Whether the finding is in the file that was run."""\n return self.run is not None and self.file == self.run[0]\n\n @property\n def source(self):\n """The run\'s code, when the finding is in it, or ""."""\n return self.run[1] if self.in_run_file else ""\n\n def _line_of_run(self, file, line):\n """A line of the code that was run - a prompt line has no file to\n read back - and only of it: another file\'s line number means\n nothing here."""\n if self.run is None or file != self.run[0] or not line:\n return None\n lines = _pll_src_re.split(r"\\r?\\n", self.run[1])\n return lines[line - 1] if 0 < line <= len(lines) else None\n\n\ndef _pll_explain_error(info, run, level):\n """The finding for the error `info` describes: from the first rule that\n explains it, or the catch-all - never None, so no student is shown a\n traceback through PLL\'s own internals."""\n error = _PllError(info, run, level)\n for types, explain in _PLL_EXPLAINERS:\n if error.type not in types:\n continue\n said = explain(error)\n if said is None:\n continue\n if isinstance(said, tuple):\n said = dict(zip(("id", "headline", "howToFix"), said))\n return _pll_runtime_finding(error, **said)\n return _pll_catch_all(error)\n\n\ndef _pll_catch_all(e):\n """The last resort: any error as a finding.\n\n It says nothing new. It puts the error in the student\'s own code and\n Python\'s message where they will read it, rather than at the end of a\n traceback through PLL\'s machinery or dozens of lines of pandas - the\n message a student needs is usually already clear (`No column named\n \'rider\' (have: month, riders)`). Rules that know an error well win.\n """\n # A table\'s rows are read from the source of the file that was run.\n row = _pll_row_line(e.source, e.line, e.message) if e.in_run_file else None\n return _pll_runtime_finding(\n e,\n id="runtime-error",\n headline=_pll_headline_for(e),\n # Deliberately empty. A generic error has no generic remedy, and\n # inventing one would be worse than the message itself.\n howToFix=[],\n lineNumber=row if row is not None else e.line,\n )\n\n\ndef _pll_headline_for(e):\n """The message as a sentence a student can read - or, a message the\n student raised themselves, as they wrote it."""\n text = e.message.strip()\n # A Stop, which the student asked for: not something their code did\n # wrong. A `KeyboardInterrupt` the program raised itself is its error.\n if e.type == "KeyboardInterrupt" and e.facts.get("stop") is True:\n return "The program was stopped."\n if not text:\n return "%s while running your program." % e.type\n innermost = e.frames[-1] if e.frames else None\n theirs = bool(innermost and innermost["user"] and _pll_src_re.match(r"\\s*raise\\b", innermost["text"] or ""))\n finish = (lambda sentence: sentence) if theirs else (lambda sentence: _pll_punctuated(_pll_picture_named(sentence)))\n if e.type == "KeyError":\n # `str(KeyError(x))` is the *repr* of x, so a message written as a\n # sentence arrives wrapped in quotes: unwrap that one. A plain\n # missing key arrives the same way (`\'rider\'`) but is not a\n # sentence, and is left exactly as Python wrote it.\n unquoted = _pll_src_re.fullmatch(r"\'([\\s\\S]*)\'", text) or _pll_src_re.fullmatch(r\'"([\\s\\S]*)"\', text)\n if unquoted is None or not _pll_src_re.search(r"\\s", unquoted.group(1)):\n return text\n return finish(unquoted.group(1))\n return finish(text)\n\n\ndef _pll_picture_named(message):\n """Python\'s message with PLL\'s own picture classes - `_Polygon`, a\n chart - named `Image`, the type a student knows them by."""\n return _pll_src_re.sub(\n r"\\b_[A-Za-z]\\w*",\n lambda found: "Image" if _pll_course_type_name(found.group(0)) == "Image" else found.group(0),\n message,\n )\n\n\ndef _pll_row_line(source, call_line, message):\n """The line of the row a `table(...)` error is about, when it can be found.\n\n The error is raised inside the library, so the student\'s innermost\n frame is the `table(` line. The message names the row by position ("the\n 2nd row, ..."), and for rows written out in place that row has a line\n of its own - the one worth pointing at. Rows passed as a variable have\n none, and the `table(` line is kept.\n """\n ordinal = _pll_src_re.search(r"\\bthe (\\d+)(?:st|nd|rd|th) row\\b", message)\n if call_line is None or ordinal is None:\n return None\n index = int(ordinal.group(1)) - 1\n lines = _pll_src_re.split(r"\\r?\\n", source)\n if not 1 <= call_line <= len(lines):\n return None\n call = _pll_src_re.search(r"\\b(?:table|Table)\\s*\\(", lines[call_line - 1])\n if call is None:\n return None\n text = "\\n".join(lines)\n # Walk the call\'s brackets from just after `table(`, skipping strings\n # and comments, so a `,` or `]` inside one does not count.\n i = sum(len(line) + 1 for line in lines[: call_line - 1]) + call.end()\n depth, argument, row, rows_open, awaiting_row = 1, 0, -1, False, False\n while i < len(text) and depth > 0:\n c = text[i]\n if c in "\\"\'":\n quote = c * 3 if text.startswith(c * 3, i) else c\n i += len(quote)\n while i < len(text) and not text.startswith(quote, i):\n i += 2 if text[i] == "\\\\" else 1\n i += len(quote)\n continue\n if c == "#":\n while i < len(text) and text[i] != "\\n":\n i += 1\n continue\n if c.isspace():\n i += 1\n continue\n # The first character of a row: where it starts.\n if awaiting_row and c != "]":\n row += 1\n if row == index:\n return text.count("\\n", 0, i) + 1\n awaiting_row = False\n if c in "([{":\n depth += 1\n if depth == 2 and argument == 1 and c == "[" and not rows_open:\n rows_open = awaiting_row = True\n elif c in ")]}":\n depth -= 1\n if rows_open and depth == 1:\n # The rows list closed without reaching the row.\n return None\n elif c == ",":\n if depth == 1:\n argument += 1\n if argument > 1:\n return None\n elif depth == 2 and rows_open:\n awaiting_row = True\n elif depth == 1 and argument == 1 and not rows_open:\n # The rows argument is not a list written out here.\n return None\n i += 1\n return None\n\n\n@_pll_explains("ChecksFailed")\ndef _pll_explain_checks_failed(e):\n """One of the student\'s files that another imports, refused because it\n did not pass its own level\'s checks (`imports.py`). Reported at the\n `import`, which is where the program stopped, with each problem in the\n file worded as a run of that file words it."""\n checks = e.facts.get("checks")\n if checks is None:\n return None\n file, problem = checks["file"], checks["header_problem"]\n if problem is not None:\n problems = ["Line %s: %s" % (problem["line"], problem["message"])]\n headline = "`%s` was not imported: there is a problem with its `#level` line." % file\n else:\n problems = ["Line %s: %s" % (f["lineNumber"], f["headline"]) for f in checks["findings"]]\n count = "a problem" if len(problems) == 1 else "%d problems" % len(problems)\n headline = "`%s` was not imported: the checks of `#level %s` found %s in it." % (file, checks["level"], count)\n return {\n "id": "checks-failed",\n "errorType": "ChecksFailed",\n "headline": headline,\n "howToFix": problems + ["Run `%s` itself to see %s where it is." % (file, "it" if len(problems) == 1 else "each one")],\n }\n\n\ndef _pll_unexplained(info, run):\n """A finding in Python\'s own words, where the error is: for an error\n whose explanation failed."""\n message = info["message"].strip()\n return {\n "id": "runtime-error",\n "errorType": info["errorType"],\n "headline": message or "%s while running your program." % info["errorType"],\n "howToFix": [],\n "fileName": info["fileName"] if info["fileName"] is not None else (run[0] if run else None),\n "lineNumber": info["lineNumber"],\n "column": info["column"],\n "nameToken": info["facts"].get("name"),\n "severity": "error",\n "origin": "runtime",\n }\n\n\ndef _pll_runtime_finding(error, **fields):\n """A runtime finding about `error`, given what it says differently from\n the error: what Python called it, and where it is, are the error\'s own."""\n finding = {\n "errorType": error.type,\n "fileName": error.file,\n "lineNumber": error.line,\n "column": error.column,\n "nameToken": error.name,\n "severity": "error",\n "origin": "runtime",\n }\n finding.update(fields)\n return finding\n\n\ndef _pll_punctuated(text):\n """The text as a sentence: trimmed, and ending in a full stop if it had none."""\n trimmed = text.strip()\n return trimmed if trimmed.endswith((".", "!", "?")) else trimmed + "."\n\n\n# What the explanations know about PLL\'s own library that its definitions\n# do not say: the functions that call one of the student\'s, what each\n# passes it, and what to add to an arity error about one.\n\n#: The library functions that call a function the student passes them, and\n#: what they call it with. `each` and `param` are for the table methods,\n#: whose function decides about one row or value at a time. The distinction\n#: that matters is `transform_column`, which passes one *value* from a\n#: column, against `filter` and `add_column`, which pass a whole row.\n_PLL_FUNCTION_TAKERS = {\n "filter": {\n "calls": "`filter` calls your function with one row at a time",\n "each": "each row",\n "param": "r: dict",\n "keeps": "keeps the rows where it returns `True`",\n },\n "transform_column": {\n "calls": "`transform_column` calls your function with one *value* from the column, not a row",\n "each": "each value in the column",\n "param": "v",\n "keeps": "puts what it returns in place of the value",\n },\n "add_column": {\n "calls": "`add_column` calls your function with one row at a time",\n "each": "each row",\n "param": "r: dict",\n "keeps": "puts what it returns in the new column",\n },\n "animate": {"calls": "`animate` calls your function with the tick count, a number"},\n "big_bang": {"calls": "`big_bang` calls your handlers with the state"},\n}\n\n#: A reactor\'s handlers, which are given a function by keyword: `to_draw=draw`.\n_PLL_HANDLER_KEYWORDS = ("to_draw", "on_tick", "stop_when", "on_key", "on_mouse", "on_receive")\n\n#: Extra lines for an arity error on a library function, where knowing the\n#: parameter names is not enough. `table` is the one that matters: the shape\n#: of its two arguments - names first, then every row inside one more list -\n#: is what goes wrong, and "takes 2 arguments" does not say it.\n_PLL_LIBRARY_HINTS = {\n "table": [\n \'The column names come first, as a list of strings, then all the rows inside one more list:\'\n \' `table(["month", "riders"], [["Jan", 1], ["Feb", 2]])`.\'\n ],\n "table_from_columns": [\n \'`table_from_columns` takes one dictionary: `table_from_columns({"month": [...], "riders": [...]})`.\'\n ],\n}\n\ndef _pll_table_method(name):\n """Whether `name` is a method of a table, which is called on one:\n `people.order_by(...)`. Read off `Table` when asked: the table library\n loads after this file."""\n table = globals().get("Table")\n return isinstance(table, type) and not name.startswith("_") and callable(vars(table).get(name))\n\n\ndef _pll_library_caller(frames):\n """The library function that called the student\'s function, if one did -\n "reactor" for any frame in the reactor library, whichever of its methods\n was driving. Its frames are PLL\'s, not the student\'s, but knowing which\n one it was changes the advice completely."""\n for frame in frames:\n if frame["file"] == "<pll:reactor>":\n return "reactor"\n if frame["file"] in ("<pll:table>", "<pll:image>") and frame["function"] in _PLL_FUNCTION_TAKERS:\n return frame["function"]\n return None\n\n\n#: Plain-language words for the types a beginner course uses, without an\n#: article: the one table every explanation uses, so no two of them can name\n#: a type differently.\n_PLL_TYPE_WORDS = {\n "int": "whole number",\n "float": "number",\n "complex": "complex number",\n "str": "string",\n "bool": "`True` or `False`",\n "bytes": "bytes",\n "list": "list",\n "dict": "dictionary",\n "set": "set",\n "tuple": "tuple",\n "range": "range",\n "function": "function",\n "None": "`None`",\n "NoneType": "`None`",\n}\n\n\ndef _pll_type_words(type_name, article=False):\n """The words for a type: "whole number" - or, with `article`, "a whole\n number". None for a type with no plain name, such as a class the\n student wrote. Phrases that are not a noun ("`True` or `False`",\n "bytes") never take an article."""\n words = _PLL_TYPE_WORDS.get(type_name)\n if words is None or not article or words.startswith("`") or words == "bytes":\n return words\n return "a " + words\n\n\ndef _pll_plural(count, noun):\n """"1 argument", "2 arguments"."""\n return "%d %s%s" % (count, noun, "" if count == 1 else "s")\n';
216
216
 
217
217
  // src/python/explain/names.py
218
- var names_default = '# Errors about names and syntax: a name Python does not know, or does not\n# have a value for yet, and a line it cannot read.\n\n#: What a student has to write to get a name an import provides. `pd` is\n#: the one that matters most: Python\'s own suggestion for it is `id`, a\n#: real built-in and nothing to do with pandas.\n_PLL_IMPORT_FOR = {\n "dataclass": "from dataclasses import dataclass",\n "field": "from dataclasses import field",\n "pd": "import pandas as pd",\n "pandas": "import pandas",\n "np": "import numpy as np",\n "numpy": "import numpy",\n "math": "import math",\n "pytest": "import pytest",\n "statistics": "import statistics",\n "random": "import random",\n "Callable": "from typing import Callable",\n "Optional": "from typing import Optional",\n "Any": "from typing import Any",\n "datetime": "import datetime",\n "json": "import json",\n "csv": "import csv",\n "re": "import re",\n}\n\n#: Python\'s keywords, which a student may reach for as a variable name.\n_PLL_KEYWORDS = frozenset(\n "False None True and as assert async await break class continue def del elif else except finally for from"\n " global if import in is lambda nonlocal not or pass raise return try while with yield".split()\n)\n\n\n@_pll_explains("SyntaxError", "IndentationError", "TabError")\ndef _pll_explain_syntax(e):\n """Better wording for the syntax errors students actually hit.\n\n Python\'s own message is kept whenever it is already clear (`expected\n \':\'`, `\'(\' was never closed`). These are the ones where it is not: it\n names the token that finally failed to parse rather than the mistake,\n so `else if` becomes "expected \':\'" and `class = 30` becomes "invalid\n syntax" pointing at the `=`. The student\'s line is what makes them\n recognisable: Python has already forgotten what the construct was.\n """\n run = getattr(e, "run", None)\n source = run[1] if run and e.file == run[0] else None\n headline, how_to_fix = _pll_syntax_wording(e.type, e.message, e.text, source, e.line)\n return {"id": "syntax-error", "headline": headline, "howToFix": how_to_fix, "nameToken": None}\n\n\ndef _pll_syntax_wording(error_type, message, line, source=None, line_number=None):\n """`(headline, how_to_fix)` for a syntax error on `line`: the first\n rewording that fits. `source` is the program, when there is one, and\n `line_number` where in it the line is."""\n line = (line or "").strip()\n if error_type == "TabError":\n return "This line mixes tabs and spaces for its indentation.", [\n "Use spaces throughout. In VS Code, the command **Convert Indentation to Spaces** fixes the whole file.",\n "A tab and four spaces look identical on screen, which is why this is hard to see.",\n ]\n bad = _pll_src_re.search(r"invalid character \'(.)\' \\(U\\+[0-9A-F]+\\)", message)\n if bad:\n if bad.group(1) in "\u201C\u201D\u2018\u2019":\n return "`%s` is not a character Python can read here." % bad.group(1), [\n \'Use straight quotes (`"` and `\\\'`) rather than curly ones.\',\n "Curly quotes come from copying out of a document or a web page; retyping the quotes fixes it.",\n ]\n return "`%s` is not a character Python can read here." % bad.group(1), [\n "Retype the line; the character may have come from copying out of a document."\n ]\n definition = _pll_definition_mistake(line, source, line_number)\n if definition is not None:\n return definition\n # `else if` parses as `else` followed by an expression, so Python asks\n # for the colon it wanted after `else`.\n if _pll_src_re.match(r"else\\s+if\\b", line):\n return "Python spells this `elif`, not `else if`.", ["Write `elif` as one word, then the condition and a colon."]\n if "\'return\' outside function" in message:\n return "`return` only works inside a function.", [\n "To show a value at the top level of a file, use `print(...)` or write the value on a line of its own.",\n "If this was meant to be inside a function, check that it is indented under the `def`.",\n ]\n compare = ["`==` asks whether two values are equal; `=` gives a name a value."]\n # Python suggests `==` *or* `:=`. The walrus is not something a student\n # in this course has met, and offering it invites a second mistake.\n if "Maybe you meant \'==\'" in message:\n if _pll_src_re.match(r"assert\\b", line):\n return "A test compares with `==`, not `=`.", compare\n return "This compares two values, so it needs `==`.", compare\n # `assert f(x) = 0` does not reach the suggestion above - the left side\n # is a call, which cannot be assigned to at all, so Python just gives up.\n if _pll_src_re.match(r"assert\\b", line) and _pll_src_re.search(r"(^|[^=!<>])=([^=]|$)", line):\n return "A test compares with `==`, not `=`.", compare\n # `case Boa:` parses, and only fails when compiled. Python\'s message is\n # about the consequence - the cases below can never run - rather than\n # the cause, which is the missing brackets.\n capture = _pll_src_re.search(r"name capture \'(\\w+)\' makes remaining patterns unreachable", message)\n if capture:\n name = capture.group(1)\n return "`case %s:` needs brackets: `case %s():`." % (name, name), [\n "Without them, `%s` is a new name that matches anything, so no `case` after it can ever run." % name,\n "With them, `case %s():` matches only a `%s`; put one name inside for each field." % (name, name),\n ]\n assigned = _pll_src_re.match(r"([A-Za-z_][A-Za-z0-9_]*)\\s*(?::[^=]+)?=[^=]", line)\n if assigned and assigned.group(1) in _PLL_KEYWORDS:\n keyword = assigned.group(1)\n return "`%s` is a word Python reserves, so it cannot be a name." % keyword, [\n "Pick another name, such as `%s_value` or something describing what it holds." % keyword\n ]\n return (_pll_punctuated(message) if message.strip() else "Python could not read this line."), []\n\n\n#: A line that starts a function the way another language does, or with\n#: no name: `def`, or Pyret\'s `fun`; a name, which may have Pyret\'s `-` in\n#: it; and the brackets, and the rest of the line as written.\n_PLL_DEFINITION_LINE = _pll_src_re.compile(\n r"(?P<keyword>async\\s+def|def|fun)\\b\\s*(?P<name>[A-Za-z_]\\w*(?:-\\w+)*)?(?P<rest>\\s*\\(.*)$"\n)\n\n\ndef _pll_definition_mistake(line, source=None, line_number=None):\n """`def foo-bar(x):`, `def (x):` and Pyret\'s `fun foo(x):`, said as\n what they are - but only a line that is exactly one of those: mended as\n said, it has to parse, with the rest of the program around it when there\n is one. Anything else wrong with it, and nothing here is said."""\n found = _PLL_DEFINITION_LINE.match(line)\n if found is None:\n return None\n keyword, name, rest = found.group("keyword"), found.group("name"), found.group("rest")\n pyret = keyword == "fun"\n hyphens = name is not None and "-" in name\n if (pyret and name is None) or (not pyret and name is not None and not hyphens):\n return None\n mended = (name or "function_name").replace("-", "_")\n written = ("async def" if keyword.startswith("async") else "def") + " " + mended + rest\n if not _pll_parses_mended(line, written, source, line_number):\n return None\n call = "`%s(...)`" % mended\n if pyret:\n headline = "Python defines a function with `def`, not `fun`%s." % (\n " - and a name cannot have a `-` in it" if hyphens else ""\n )\n how_to_fix = ["Write `def %s(...)`%s." % (mended, ", and call it %s too" % call if hyphens else "")]\n end = _pll_pyret_end(source, line_number)\n if end is not None:\n how_to_fix.append(\n "Take out the `end` on line %d too: a Python function ends where its indented lines do." % end\n )\n return headline, how_to_fix\n if name is None:\n return "This `def` has no name.", [\n "Put one between `def` and the brackets - `def %s(...)` - with a name that says what it does." % mended\n ]\n return "A function\'s name cannot have a `-` in it: Python reads `%s` as `%s`." % (\n name, "` minus `".join(name.split("-"))\n ), [\n "Write `def %s(...)`, with %s, and call it %s too."\n % (mended, "an underscore" if name.count("-") == 1 else "underscores", call)\n ]\n\n\ndef _pll_parses_mended(line, written, source, line_number):\n """Whether `line`, written as `written`, parses: in `source`, at\n `line_number`, when there is a program - a later line failing is\n another mistake, not this one - or else on its own, as a whole\n definition."""\n if source is not None and line_number:\n lines = source.split("\\n")\n if not 0 < line_number <= len(lines) or lines[line_number - 1].strip() != line:\n return False\n old = lines[line_number - 1]\n lines[line_number - 1] = old[: len(old) - len(old.lstrip())] + written + ("\\r" if old.endswith("\\r") else "")\n try:\n _ast.parse("\\n".join(lines))\n except SyntaxError as err:\n return err.lineno is not None and err.lineno > line_number\n return True\n for attempt in (written, written + "\\n pass"):\n try:\n _ast.parse(attempt)\n return True\n except SyntaxError:\n continue\n return False\n\n\ndef _pll_pyret_end(source, line_number):\n """The first line after `line_number` that is only `end`, as a Pyret\n function is closed with, or None."""\n if source is None or not line_number:\n return None\n for number, text in enumerate(source.split("\\n")[line_number:], start=line_number + 1):\n if text.strip() == "end":\n return number\n return None\n\n\n@_pll_explains("NameError", "UnboundLocalError")\ndef _pll_explain_name(e):\n """A name Python does not know - or, `UnboundLocalError`, the same\n mistake to a student: a name used before it has a value.\n\n The message is the one Python shows - `name \'Totl\' is not defined. Did\n you mean: \'total\'?` - so its suggestion is in it, and becomes a "Did\n you mean" line of its own rather than part of the headline.\n """\n headline, how_to_fix = _pll_name_wording(e)\n return "name-error", headline, how_to_fix\n\n\ndef _pll_name_wording(e):\n named = e.name is not None\n name = e.name if named else "this name"\n line = e.text\n # What a check made before the run knows, as facts: where the name lost\n # its value, or that it is the class still being made.\n deleted = e.facts.get("deleted_on")\n if named and deleted is not None:\n return "`%s` was removed by the `del` on line %d, so it has no value here." % (name, deleted), [\n "Use `%s` before the `del`, or leave the `del` out." % name\n ]\n handler = e.facts.get("except_on")\n if named and handler is not None:\n return "`%s` has a value only inside the `except` on line %d: Python removes it when that block ends." % (\n name, handler\n ), ["Use it inside the `except` block, or keep what you need in another name there: `error = %s`." % name]\n if named and e.facts.get("being_defined"):\n headline = "`%s` is the class being defined here, and has no value until the class is finished." % name\n annotated = _pll_annotation_target(line, name)\n if annotated is not None:\n example = _pll_as_string_annotation(line, annotated, name)\n return headline, [\n "Write the type as a string - `%s` - and Python looks it up later, once the class exists." % example\n ]\n return headline, ["Use it inside a method: a method runs later, once the class exists."]\n # Read in a function that a call runs too early: the mistake is the\n # order of that call and the line setting the name, not this line.\n called_on = e.facts.get("called_on")\n if named and called_on is not None:\n path = e.facts.get("called") or ["this function"]\n # "`a` uses it", or "`a` calls `b`, which uses it".\n who = "`%s`" % path[0] + "".join(" calls `%s`, which" % function for function in path[1:])\n headline = "Line %d calls `%s` before `%s` has a value, and %s uses it here." % (called_on, path[0], name, who)\n set_on = e.facts.get("defined_on")\n if set_on is not None:\n return headline, [\n "`%s` gets its value on line %d, after the call." % (name, set_on),\n "Move the call below line %d, or move the line that sets `%s` above line %d."\n % (set_on, name, called_on),\n ]\n return headline, ["Give `%s` a value before line %d, where `%s` is called." % (name, called_on, path[0])]\n # "Never heard of it" and "not assigned yet" need opposite advice: the\n # second is a name spelled correctly, so telling the student to check\n # the spelling sends them looking for a mistake that is not there. What\n # is wrong is the order.\n unbound = _pll_src_re.search(r"cannot access (free|local) variable \'", e.message)\n if unbound:\n return "`%s` has not been given a value yet on this line." % name, [\n "Move the line that sets `%s` above this one." % name,\n "If you meant a `%s` from outside this function: assigning to it anywhere inside makes it local to the"\n " whole function, so the outer one is not visible here. Pass it in as a parameter instead." % name\n if unbound.group(1) == "local"\n else "The `%s` here belongs to the function around this one, which sets it after this line runs." % name,\n ]\n pattern = e.facts.get("pattern_class")\n if pattern is not None:\n return _pll_pattern_class_wording(\n name, pattern["subject"], pattern["union"], pattern["members"], pattern["classes"]\n )\n if named and line is not None:\n escaped = _pll_src_re.escape(name)\n # A function\'s parameter, used where the function itself belonged:\n # `filter(below_1k(r))` calls `below_1k` right there, so `r` has no\n # value - a symptom of passing the result rather than the function.\n for taker in _PLL_FUNCTION_TAKERS:\n passed = _pll_src_re.search(r"\\b%s\\s*\\(\\s*([A-Za-z_]\\w*)\\s*\\(\\s*%s\\s*\\)" % (taker, escaped), line)\n if passed:\n fn = passed.group(1)\n return "`%s` calls `%s` for you, so `%s` is not something you write here." % (taker, fn, name), [\n "Pass the function itself: `%s(%s)`." % (taker, fn),\n "`%s` supplies the argument for each row, so `%s` is never called by hand." % (taker, fn),\n ]\n # A `-` where a name has `_`: Python reads `load-table(...)` as\n # `load` minus `table(...)`. Only a function\'s name, called:\n # `total-tax` on its own is as likely a subtraction.\n for found in _pll_src_re.finditer(r"(?<![.\\w])([A-Za-z_]\\w*(?:-[A-Za-z_]\\w*)+)(?=\\s*\\()", line):\n hyphen = found.group(1)\n if name in hyphen.split("-"):\n return "Python reads `%s` as `%s`: a name cannot have a `-` in it." % (\n hyphen, "` minus `".join(hyphen.split("-"))\n ), ["Write `%s`, with an underscore." % hyphen.replace("-", "_")]\n # A table\'s method, called as if it were a function of its own.\n if _pll_table_method(name):\n method = _pll_src_re.search(r"\\b%s\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*(?:,\\s*([^)]*))?\\)" % name, line)\n if method:\n return "`%s` is a method of a table, so it is called on one." % name, [\n "Write `%s.%s(%s)`." % (method.group(1), name, (method.group(2) or "").strip())\n ]\n if named and name in _PLL_IMPORT_FOR:\n return "`%s` comes from an import, and this file has not imported it." % name, [\n "Add `%s` at the top of the file." % _PLL_IMPORT_FOR[name]\n ]\n # Defined, but further down: Python runs the file from the top.\n later = e.facts.get("defined_on")\n if later is None and named:\n later = _pll_defined_later_at(e.source, name, e.info["lineNumber"])\n if later is not None:\n # In an annotation, moving the definition up is not the fix and is\n # often impossible: a recursive type names itself.\n annotated = _pll_annotation_target(line, name)\n if annotated is not None:\n example = _pll_as_string_annotation(line, annotated, name)\n return "`%s` is defined on line %d, after this line, and an annotation is read as the file runs." % (\n name, later\n ), [\n "Write the type as a string - `%s` - and Python looks it up later, once it exists." % example,\n "A type that names itself can only be written that way: the union needs the class, and the class"\n " needs the union.",\n ]\n return "`%s` is defined on line %d, after this line." % (name, later), [\n "Python runs a file from the top, so nothing further down exists yet.",\n "Move the definition of `%s` above this line." % name,\n ]\n # A type by another language\'s name - `string`, `Number`, `List` - for\n # which Python suggests nothing, or a module to import.\n as_type = named and _pll_read_as_type(e)\n if as_type and name in _PLL_NOT_A_TYPE:\n return "`%s` is not a type Python knows." % name, _pll_type_fix(name)\n headline = "Python doesn\'t know what `%s` means." % name\n meant = _pll_did_you_mean(e.message)\n # One suggestion is a question, not "one of those". Differing only in\n # case is the whole story, and a confident match makes the spelling and\n # the quotes beside the point.\n if len(meant) == 1:\n if meant[0].lower() == name.lower():\n return headline, [\n "Did you mean `%s`? Python names are case-sensitive, so `%s` and `%s` are different names."\n % (meant[0], name, meant[0])\n ]\n return headline, ["Did you mean `%s`?" % meant[0]]\n how_to_fix = [\n "Check the spelling of `%s` (Python is case-sensitive)." % name,\n "Make sure `%s` is defined before this line runs." % name,\n ]\n if as_type:\n # Quoting a type is a forward reference, not text: it names the\n # same missing thing.\n how_to_fix.append("An annotation names a type: `int`, `float`, `str`, `bool`, `list`, `dict`, or a class of your own.")\n elif named:\n # Only worth saying with a real name to quote: `put "this name" in\n # quotes` is advice about the placeholder rather than about the code.\n how_to_fix.append(\'If `%s` should be text, put it in quotes: `"%s"`.\' % (name, name))\n if len(meant) > 1:\n how_to_fix.insert(0, "Did you mean one of %s?" % ", ".join("`%s`" % m for m in meant))\n return headline, how_to_fix\n\n\ndef _pll_type_fix(written):\n """What to write for `written`, a type by a name Python does not use\n (`_PLL_NOT_A_TYPE`): one line naming the type - a number, either."""\n meant = _PLL_NOT_A_TYPE[written]\n fix = ["Write `%s` instead." % meant]\n if written.lower() == "number":\n fix.append("Or `int`, if it is always a whole number.")\n return fix\n\n\ndef _pll_read_as_type(e):\n """Whether the name `e` is about is read as a type: in an annotation,\n or as a `type` alias\'s value. A check before the run says so; for a\n run\'s error it is read off the program, at the error\'s line and column."""\n if e.facts.get("in_annotation"):\n return True\n run = getattr(e, "run", None)\n line = getattr(e, "line", None)\n if not run or line is None or getattr(e, "file", None) != run[0]:\n return False\n return _pll_annotates(run[1], line, getattr(e, "column", None), e.name)\n\n\ndef _pll_annotates(source, line, column, name):\n """Whether `name`, read on `line` (at `column`, when known) of `source`,\n is in an annotation or a `type` alias\'s value."""\n try:\n tree = _ast.parse(source)\n except SyntaxError:\n return False\n for node in _ast.walk(tree):\n types = []\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n args = node.args\n every = args.posonlyargs + args.args + args.kwonlyargs + [a for a in (args.vararg, args.kwarg) if a]\n types = [a.annotation for a in every if a.annotation is not None]\n types += [node.returns] if node.returns is not None else []\n elif isinstance(node, _ast.AnnAssign):\n types = [node.annotation]\n elif isinstance(node, getattr(_ast, "TypeAlias", ())):\n types = [node.value]\n for written in types:\n for read in _ast.walk(written):\n if (\n isinstance(read, _ast.Name)\n and read.id == name\n and read.lineno == line\n and (column is None or read.col_offset == column)\n ):\n return True\n return False\n\n\ndef _pll_defined_later_at(source, name, used_on):\n """The line `name` is defined on, when that is after `used_on`. Python\n runs a file from the top, so a name defined further down does not exist\n yet - and "check the spelling" sends the student looking for a mistake\n that is not there."""\n if not source or used_on is None:\n return None\n escaped = _pll_src_re.escape(name)\n defined = _pll_src_re.compile(\n r"[ \\t]*(?:def[ \\t]+%s[ \\t]*\\(|class[ \\t]+%s\\b|%s[ \\t]*(?::[^=]+)?=[^=])" % (escaped, escaped, escaped)\n )\n lines = _pll_src_re.split(r"\\r?\\n", source)\n for i in range(used_on, len(lines)):\n if defined.match(lines[i]):\n return i + 1\n return None\n\n\ndef _pll_annotation_target(line, name):\n """What `name` annotates on this line: a field, variable or parameter\'s\n name, "" for a return type, or None when it is no annotation.\n\n `rest: NumList` inside the very class the union is built from cannot be\n fixed by moving anything: `NumList = Empty | Link` needs `Link`, and\n `Link` needs `NumList`. Python\'s answer is the string form, resolved\n later - the shape of every recursive data definition in the course.\n """\n if line is None:\n return None\n escaped = _pll_src_re.escape(name)\n named = _pll_src_re.match(r"\\s*([A-Za-z_]\\w*)\\s*:\\s*%s\\b" % escaped, line) or _pll_src_re.search(\n r"[(,]\\s*([A-Za-z_]\\w*)\\s*:\\s*%s\\b" % escaped, line\n )\n if named:\n return named.group(1)\n return "" if _pll_src_re.search(r"->\\s*%s\\b" % escaped, line) else None\n\n\ndef _pll_as_string_annotation(line, target, name):\n """`rest: "Link | None"`: the annotation on `line` for `target` ("" for\n a return type), all of it as a string - or, when it cannot be read off\n the line whole, just `name`."""\n if target:\n found = _pll_src_re.search(r"(?:^|[(,])\\s*%s\\s*:\\s*([^=#]+)" % _pll_src_re.escape(target), line)\n else:\n found = _pll_src_re.search(r"->\\s*([^#]+?)\\s*:\\s*(?:#.*)?$", line)\n written = found.group(1).strip() if found else ""\n # A parameter\'s annotation ends at the next `,` or `)` outside brackets.\n depth = 0\n for i, c in enumerate(written):\n if c in "([{":\n depth += 1\n elif c in ")]}":\n if depth == 0:\n written = written[:i]\n break\n depth -= 1\n elif c == "," and depth == 0:\n written = written[:i]\n break\n written = written.strip()\n if not written or depth != 0 or name not in written:\n written = name\n return \'%s: "%s"\' % (target, written) if target else \'-> "%s"\' % written\n\n\ndef _pll_did_you_mean(message):\n """The names Python suggests in "Did you mean: \'total\'?"."""\n found = _pll_src_re.search(r"Did you mean:\\s*([^?]+)\\??", message, _pll_src_re.I)\n if not found:\n return []\n parts = _pll_src_re.split(r",|\\bor\\b", found.group(1))\n names = (_pll_src_re.sub(r"^[\'\\"`]|[\'\\"`]$", "", part.strip()) for part in parts)\n return [name for name in names if name]\n';
218
+ var names_default = '# Errors about names and syntax: a name Python does not know, or does not\n# have a value for yet, and a line it cannot read.\n\n#: What a student has to write to get a name an import provides. `pd` is\n#: the one that matters most: Python\'s own suggestion for it is `id`, a\n#: real built-in and nothing to do with pandas.\n_PLL_IMPORT_FOR = {\n "dataclass": "from dataclasses import dataclass",\n "field": "from dataclasses import field",\n "pd": "import pandas as pd",\n "pandas": "import pandas",\n "np": "import numpy as np",\n "numpy": "import numpy",\n "math": "import math",\n "pytest": "import pytest",\n "statistics": "import statistics",\n "random": "import random",\n "Callable": "from typing import Callable",\n "Optional": "from typing import Optional",\n "Any": "from typing import Any",\n "datetime": "import datetime",\n "json": "import json",\n "csv": "import csv",\n "re": "import re",\n}\n\n#: Python\'s keywords, which a student may reach for as a variable name.\n_PLL_KEYWORDS = frozenset(\n "False None True and as assert async await break class continue def del elif else except finally for from"\n " global if import in is lambda nonlocal not or pass raise return try while with yield".split()\n)\n\n\n@_pll_explains("SyntaxError", "IndentationError", "TabError")\ndef _pll_explain_syntax(e):\n """Better wording for the syntax errors students actually hit.\n\n Python\'s own message is kept whenever it is already clear (`expected\n \':\'`, `\'(\' was never closed`). These are the ones where it is not: it\n names the token that finally failed to parse rather than the mistake,\n so `else if` becomes "expected \':\'" and `class = 30` becomes "invalid\n syntax" pointing at the `=`. The student\'s line is what makes them\n recognisable: Python has already forgotten what the construct was.\n """\n run = getattr(e, "run", None)\n source = run[1] if run and e.file == run[0] else None\n headline, how_to_fix = _pll_syntax_wording(e.type, e.message, e.text, source, e.line)\n return {"id": "syntax-error", "headline": headline, "howToFix": how_to_fix, "nameToken": None}\n\n\ndef _pll_syntax_wording(error_type, message, line, source=None, line_number=None):\n """`(headline, how_to_fix)` for a syntax error on `line`: the first\n rewording that fits. `source` is the program, when there is one, and\n `line_number` where in it the line is."""\n line = (line or "").strip()\n if error_type == "TabError":\n return "This line mixes tabs and spaces for its indentation.", [\n "Use spaces throughout. In VS Code, the command **Convert Indentation to Spaces** fixes the whole file.",\n "A tab and four spaces look identical on screen, which is why this is hard to see.",\n ]\n bad = _pll_src_re.search(r"invalid character \'(.)\' \\(U\\+[0-9A-F]+\\)", message)\n if bad:\n if bad.group(1) in "\u201C\u201D\u2018\u2019":\n return "`%s` is not a character Python can read here." % bad.group(1), [\n \'Use straight quotes (`"` and `\\\'`) rather than curly ones.\',\n "Curly quotes come from copying out of a document or a web page; retyping the quotes fixes it.",\n ]\n return "`%s` is not a character Python can read here." % bad.group(1), [\n "Retype the line; the character may have come from copying out of a document."\n ]\n definition = _pll_definition_mistake(line, source, line_number)\n if definition is not None:\n return definition\n # `else if` parses as `else` followed by an expression, so Python asks\n # for the colon it wanted after `else`.\n if _pll_src_re.match(r"else\\s+if\\b", line):\n return "Python spells this `elif`, not `else if`.", ["Write `elif` as one word, then the condition and a colon."]\n if "\'return\' outside function" in message:\n return "`return` only works inside a function.", [\n "To show a value at the top level of a file, use `print(...)` or write the value on a line of its own.",\n "If this was meant to be inside a function, check that it is indented under the `def`.",\n ]\n compare = ["`==` asks whether two values are equal; `=` gives a name a value."]\n # Python suggests `==` *or* `:=`. The walrus is not something a student\n # in this course has met, and offering it invites a second mistake.\n if "Maybe you meant \'==\'" in message:\n if _pll_src_re.match(r"assert\\b", line):\n return "A test compares with `==`, not `=`.", compare\n return "This compares two values, so it needs `==`.", compare\n # `assert f(x) = 0` does not reach the suggestion above - the left side\n # is a call, which cannot be assigned to at all, so Python just gives up.\n if _pll_src_re.match(r"assert\\b", line) and _pll_src_re.search(r"(^|[^=!<>])=([^=]|$)", line):\n return "A test compares with `==`, not `=`.", compare\n # `case Boa:` parses, and only fails when compiled. Python\'s message is\n # about the consequence - the cases below can never run - rather than\n # the cause, which is the missing brackets.\n capture = _pll_src_re.search(r"name capture \'(\\w+)\' makes remaining patterns unreachable", message)\n if capture:\n name = capture.group(1)\n return "`case %s:` needs brackets: `case %s():`." % (name, name), [\n "Without them, `%s` is a new name that matches anything, so no `case` after it can ever run." % name,\n "With them, `case %s():` matches only %s; put one name inside for each field." % (name, _pll_a("`%s`" % name)),\n ]\n assigned = _pll_src_re.match(r"([A-Za-z_][A-Za-z0-9_]*)\\s*(?::[^=]+)?=[^=]", line)\n if assigned and assigned.group(1) in _PLL_KEYWORDS:\n keyword = assigned.group(1)\n return "`%s` is a word Python reserves, so it cannot be a name." % keyword, [\n "Pick another name, such as `%s_value` or something describing what it holds." % keyword\n ]\n return (_pll_punctuated(message) if message.strip() else "Python could not read this line."), []\n\n\n#: A line that starts a function the way another language does, or with\n#: no name: `def`, or Pyret\'s `fun`; a name, which may have Pyret\'s `-` in\n#: it; and the brackets, and the rest of the line as written.\n_PLL_DEFINITION_LINE = _pll_src_re.compile(\n r"(?P<keyword>async\\s+def|def|fun)\\b\\s*(?P<name>[A-Za-z_]\\w*(?:-\\w+)*)?(?P<rest>\\s*\\(.*)$"\n)\n\n\ndef _pll_definition_mistake(line, source=None, line_number=None):\n """`def foo-bar(x):`, `def (x):` and Pyret\'s `fun foo(x):`, said as\n what they are - but only a line that is exactly one of those: mended as\n said, it has to parse, with the rest of the program around it when there\n is one. Anything else wrong with it, and nothing here is said."""\n found = _PLL_DEFINITION_LINE.match(line)\n if found is None:\n return None\n keyword, name, rest = found.group("keyword"), found.group("name"), found.group("rest")\n pyret = keyword == "fun"\n hyphens = name is not None and "-" in name\n if (pyret and name is None) or (not pyret and name is not None and not hyphens):\n return None\n mended = (name or "function_name").replace("-", "_")\n written = ("async def" if keyword.startswith("async") else "def") + " " + mended + rest\n if not _pll_parses_mended(line, written, source, line_number):\n return None\n call = "`%s(...)`" % mended\n if pyret:\n headline = "Python defines a function with `def`, not `fun`%s." % (\n " - and a name cannot have a `-` in it" if hyphens else ""\n )\n how_to_fix = ["Write `def %s(...)`%s." % (mended, ", and call it %s too" % call if hyphens else "")]\n end = _pll_pyret_end(source, line_number)\n if end is not None:\n how_to_fix.append(\n "Take out the `end` on line %d too: a Python function ends where its indented lines do." % end\n )\n return headline, how_to_fix\n if name is None:\n return "This `def` has no name.", [\n "Put one between `def` and the brackets - `def %s(...)` - with a name that says what it does." % mended\n ]\n return "A function\'s name cannot have a `-` in it: Python reads `%s` as `%s`." % (\n name, "` minus `".join(name.split("-"))\n ), [\n "Write `def %s(...)`, with %s, and call it %s too."\n % (mended, "an underscore" if name.count("-") == 1 else "underscores", call)\n ]\n\n\ndef _pll_parses_mended(line, written, source, line_number):\n """Whether `line`, written as `written`, parses: in `source`, at\n `line_number`, when there is a program - a later line failing is\n another mistake, not this one - or else on its own, as a whole\n definition."""\n if source is not None and line_number:\n lines = source.split("\\n")\n if not 0 < line_number <= len(lines) or lines[line_number - 1].strip() != line:\n return False\n old = lines[line_number - 1]\n lines[line_number - 1] = old[: len(old) - len(old.lstrip())] + written + ("\\r" if old.endswith("\\r") else "")\n try:\n _ast.parse("\\n".join(lines))\n except SyntaxError as err:\n return err.lineno is not None and err.lineno > line_number\n return True\n for attempt in (written, written + "\\n pass"):\n try:\n _ast.parse(attempt)\n return True\n except SyntaxError:\n continue\n return False\n\n\ndef _pll_pyret_end(source, line_number):\n """The first line after `line_number` that is only `end`, as a Pyret\n function is closed with, or None."""\n if source is None or not line_number:\n return None\n for number, text in enumerate(source.split("\\n")[line_number:], start=line_number + 1):\n if text.strip() == "end":\n return number\n return None\n\n\n@_pll_explains("NameError", "UnboundLocalError")\ndef _pll_explain_name(e):\n """A name Python does not know - or, `UnboundLocalError`, the same\n mistake to a student: a name used before it has a value.\n\n The message is the one Python shows - `name \'Totl\' is not defined. Did\n you mean: \'total\'?` - so its suggestion is in it, and becomes a "Did\n you mean" line of its own rather than part of the headline.\n """\n headline, how_to_fix = _pll_name_wording(e)\n return "name-error", headline, how_to_fix\n\n\ndef _pll_name_wording(e):\n named = e.name is not None\n name = e.name if named else "this name"\n line = e.text\n # What a check made before the run knows, as facts: where the name lost\n # its value, or that it is the class still being made.\n deleted = e.facts.get("deleted_on")\n if named and deleted is not None:\n return "`%s` was removed by the `del` on line %d, so it has no value here." % (name, deleted), [\n "Use `%s` before the `del`, or leave the `del` out." % name\n ]\n handler = e.facts.get("except_on")\n if named and handler is not None:\n return "`%s` has a value only inside the `except` on line %d: Python removes it when that block ends." % (\n name, handler\n ), ["Use it inside the `except` block, or keep what you need in another name there: `error = %s`." % name]\n if named and e.facts.get("being_defined"):\n headline = "`%s` is the class being defined here, and has no value until the class is finished." % name\n annotated = _pll_annotation_target(line, name)\n if annotated is not None:\n example = _pll_as_string_annotation(line, annotated, name)\n return headline, [\n "Write the type as a string - `%s` - and Python looks it up later, once the class exists." % example\n ]\n return headline, ["Use it inside a method: a method runs later, once the class exists."]\n # Read in a function that a call runs too early: the mistake is the\n # order of that call and the line setting the name, not this line.\n called_on = e.facts.get("called_on")\n if named and called_on is not None:\n path = e.facts.get("called") or ["this function"]\n # "`a` uses it", or "`a` calls `b`, which uses it".\n who = "`%s`" % path[0] + "".join(" calls `%s`, which" % function for function in path[1:])\n headline = "Line %d calls `%s` before `%s` has a value, and %s uses it here." % (called_on, path[0], name, who)\n set_on = e.facts.get("defined_on")\n if set_on is not None:\n return headline, [\n "`%s` gets its value on line %d, after the call." % (name, set_on),\n "Move the call below line %d, or move the line that sets `%s` above line %d."\n % (set_on, name, called_on),\n ]\n return headline, ["Give `%s` a value before line %d, where `%s` is called." % (name, called_on, path[0])]\n # "Never heard of it" and "not assigned yet" need opposite advice: the\n # second is a name spelled correctly, so telling the student to check\n # the spelling sends them looking for a mistake that is not there. What\n # is wrong is the order.\n unbound = _pll_src_re.search(r"cannot access (free|local) variable \'", e.message)\n if unbound:\n return "`%s` has not been given a value yet on this line." % name, [\n "Move the line that sets `%s` above this one." % name,\n "If you meant a `%s` from outside this function: assigning to it anywhere inside makes it local to the"\n " whole function, so the outer one is not visible here. Pass it in as a parameter instead." % name\n if unbound.group(1) == "local"\n else "The `%s` here belongs to the function around this one, which sets it after this line runs." % name,\n ]\n pattern = e.facts.get("pattern_class")\n if pattern is not None:\n return _pll_pattern_class_wording(\n name, pattern["subject"], pattern["union"], pattern["members"], pattern["classes"]\n )\n if named and line is not None:\n escaped = _pll_src_re.escape(name)\n # A function\'s parameter, used where the function itself belonged:\n # `filter(below_1k(r))` calls `below_1k` right there, so `r` has no\n # value - a symptom of passing the result rather than the function.\n for taker in _PLL_FUNCTION_TAKERS:\n passed = _pll_src_re.search(r"\\b%s\\s*\\(\\s*([A-Za-z_]\\w*)\\s*\\(\\s*%s\\s*\\)" % (taker, escaped), line)\n if passed:\n fn = passed.group(1)\n return "`%s` calls `%s` for you, so `%s` is not something you write here." % (taker, fn, name), [\n "Pass the function itself: `%s(%s)`." % (taker, fn),\n "`%s` supplies the argument for each row, so `%s` is never called by hand." % (taker, fn),\n ]\n # A `-` where a name has `_`: Python reads `load-table(...)` as\n # `load` minus `table(...)`. Only a function\'s name, called:\n # `total-tax` on its own is as likely a subtraction.\n for found in _pll_src_re.finditer(r"(?<![.\\w])([A-Za-z_]\\w*(?:-[A-Za-z_]\\w*)+)(?=\\s*\\()", line):\n hyphen = found.group(1)\n if name in hyphen.split("-"):\n return "Python reads `%s` as `%s`: a name cannot have a `-` in it." % (\n hyphen, "` minus `".join(hyphen.split("-"))\n ), ["Write `%s`, with an underscore." % hyphen.replace("-", "_")]\n # A table\'s method, called as if it were a function of its own.\n if _pll_table_method(name):\n method = _pll_src_re.search(r"\\b%s\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*(?:,\\s*([^)]*))?\\)" % name, line)\n if method:\n return "`%s` is a method of a table, so it is called on one." % name, [\n "Write `%s.%s(%s)`." % (method.group(1), name, (method.group(2) or "").strip())\n ]\n if named and name in _PLL_IMPORT_FOR:\n return "`%s` comes from an import, and this file has not imported it." % name, [\n "Add `%s` at the top of the file." % _PLL_IMPORT_FOR[name]\n ]\n # Defined, but further down: Python runs the file from the top.\n later = e.facts.get("defined_on")\n if later is None and named:\n later = _pll_defined_later_at(e.source, name, e.info["lineNumber"])\n if later is not None:\n # In an annotation, moving the definition up is not the fix and is\n # often impossible: a recursive type names itself.\n annotated = _pll_annotation_target(line, name)\n if annotated is not None:\n example = _pll_as_string_annotation(line, annotated, name)\n return "`%s` is defined on line %d, after this line, and an annotation is read as the file runs." % (\n name, later\n ), [\n "Write the type as a string - `%s` - and Python looks it up later, once it exists." % example,\n "A type that names itself can only be written that way: the union needs the class, and the class"\n " needs the union.",\n ]\n return "`%s` is defined on line %d, after this line." % (name, later), [\n "Python runs a file from the top, so nothing further down exists yet.",\n "Move the definition of `%s` above this line." % name,\n ]\n # A type by another language\'s name - `string`, `Number`, `List` - for\n # which Python suggests nothing, or a module to import.\n as_type = named and _pll_read_as_type(e)\n if as_type and name in _PLL_NOT_A_TYPE:\n return "`%s` is not a type Python knows." % name, _pll_type_fix(name)\n headline = "Python doesn\'t know what `%s` means." % name\n meant = _pll_did_you_mean(e.message)\n # One suggestion is a question, not "one of those". Differing only in\n # case is the whole story, and a confident match makes the spelling and\n # the quotes beside the point.\n if len(meant) == 1:\n if meant[0].lower() == name.lower():\n return headline, [\n "Did you mean `%s`? Python names are case-sensitive, so `%s` and `%s` are different names."\n % (meant[0], name, meant[0])\n ]\n return headline, ["Did you mean `%s`?" % meant[0]]\n how_to_fix = [\n "Check the spelling of `%s` (Python is case-sensitive)." % name,\n "Make sure `%s` is defined before this line runs." % name,\n ]\n if as_type:\n # Quoting a type is a forward reference, not text: it names the\n # same missing thing.\n how_to_fix.append("An annotation names a type: `int`, `float`, `str`, `bool`, `list`, `dict`, or a class of your own.")\n elif named:\n # Only worth saying with a real name to quote: `put "this name" in\n # quotes` is advice about the placeholder rather than about the code.\n how_to_fix.append(\'If `%s` should be text, put it in quotes: `"%s"`.\' % (name, name))\n if len(meant) > 1:\n how_to_fix.insert(0, "Did you mean one of %s?" % ", ".join("`%s`" % m for m in meant))\n return headline, how_to_fix\n\n\ndef _pll_type_fix(written):\n """What to write for `written`, a type by a name Python does not use\n (`_PLL_NOT_A_TYPE`): one line naming the type - a number, either."""\n meant = _PLL_NOT_A_TYPE[written]\n fix = ["Write `%s` instead." % meant]\n if written.lower() == "number":\n fix.append("Or `int`, if it is always a whole number.")\n return fix\n\n\ndef _pll_read_as_type(e):\n """Whether the name `e` is about is read as a type: in an annotation,\n or as a `type` alias\'s value. A check before the run says so; for a\n run\'s error it is read off the program, at the error\'s line and column."""\n if e.facts.get("in_annotation"):\n return True\n run = getattr(e, "run", None)\n line = getattr(e, "line", None)\n if not run or line is None or getattr(e, "file", None) != run[0]:\n return False\n return _pll_annotates(run[1], line, getattr(e, "column", None), e.name)\n\n\ndef _pll_annotates(source, line, column, name):\n """Whether `name`, read on `line` (at `column`, when known) of `source`,\n is in an annotation or a `type` alias\'s value."""\n try:\n tree = _ast.parse(source)\n except SyntaxError:\n return False\n for node in _ast.walk(tree):\n types = []\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n args = node.args\n every = args.posonlyargs + args.args + args.kwonlyargs + [a for a in (args.vararg, args.kwarg) if a]\n types = [a.annotation for a in every if a.annotation is not None]\n types += [node.returns] if node.returns is not None else []\n elif isinstance(node, _ast.AnnAssign):\n types = [node.annotation]\n elif isinstance(node, getattr(_ast, "TypeAlias", ())):\n types = [node.value]\n for written in types:\n for read in _ast.walk(written):\n if (\n isinstance(read, _ast.Name)\n and read.id == name\n and read.lineno == line\n and (column is None or read.col_offset == column)\n ):\n return True\n return False\n\n\ndef _pll_defined_later_at(source, name, used_on):\n """The line `name` is defined on, when that is after `used_on`. Python\n runs a file from the top, so a name defined further down does not exist\n yet - and "check the spelling" sends the student looking for a mistake\n that is not there."""\n if not source or used_on is None:\n return None\n escaped = _pll_src_re.escape(name)\n defined = _pll_src_re.compile(\n r"[ \\t]*(?:def[ \\t]+%s[ \\t]*\\(|class[ \\t]+%s\\b|%s[ \\t]*(?::[^=]+)?=[^=])" % (escaped, escaped, escaped)\n )\n lines = _pll_src_re.split(r"\\r?\\n", source)\n for i in range(used_on, len(lines)):\n if defined.match(lines[i]):\n return i + 1\n return None\n\n\ndef _pll_annotation_target(line, name):\n """What `name` annotates on this line: a field, variable or parameter\'s\n name, "" for a return type, or None when it is no annotation.\n\n `rest: NumList` inside the very class the union is built from cannot be\n fixed by moving anything: `NumList = Empty | Link` needs `Link`, and\n `Link` needs `NumList`. Python\'s answer is the string form, resolved\n later - the shape of every recursive data definition in the course.\n """\n if line is None:\n return None\n escaped = _pll_src_re.escape(name)\n named = _pll_src_re.match(r"\\s*([A-Za-z_]\\w*)\\s*:\\s*%s\\b" % escaped, line) or _pll_src_re.search(\n r"[(,]\\s*([A-Za-z_]\\w*)\\s*:\\s*%s\\b" % escaped, line\n )\n if named:\n return named.group(1)\n return "" if _pll_src_re.search(r"->\\s*%s\\b" % escaped, line) else None\n\n\ndef _pll_as_string_annotation(line, target, name):\n """`rest: "Link | None"`: the annotation on `line` for `target` ("" for\n a return type), all of it as a string - or, when it cannot be read off\n the line whole, just `name`."""\n if target:\n found = _pll_src_re.search(r"(?:^|[(,])\\s*%s\\s*:\\s*([^=#]+)" % _pll_src_re.escape(target), line)\n else:\n found = _pll_src_re.search(r"->\\s*([^#]+?)\\s*:\\s*(?:#.*)?$", line)\n written = found.group(1).strip() if found else ""\n # A parameter\'s annotation ends at the next `,` or `)` outside brackets.\n depth = 0\n for i, c in enumerate(written):\n if c in "([{":\n depth += 1\n elif c in ")]}":\n if depth == 0:\n written = written[:i]\n break\n depth -= 1\n elif c == "," and depth == 0:\n written = written[:i]\n break\n written = written.strip()\n if not written or depth != 0 or name not in written:\n written = name\n return \'%s: "%s"\' % (target, written) if target else \'-> "%s"\' % written\n\n\ndef _pll_did_you_mean(message):\n """The names Python suggests in "Did you mean: \'total\'?"."""\n found = _pll_src_re.search(r"Did you mean:\\s*([^?]+)\\??", message, _pll_src_re.I)\n if not found:\n return []\n parts = _pll_src_re.split(r",|\\bor\\b", found.group(1))\n names = (_pll_src_re.sub(r"^[\'\\"`]|[\'\\"`]$", "", part.strip()) for part in parts)\n return [name for name in names if name]\n';
219
219
 
220
220
  // src/python/explain/typeCheck.py
221
221
  var typeCheck_default = '# A value that does not match its annotation: typeguard\'s `TypeCheckError`,\n# from the parts Python read its message into (`_pll_type_check_parts`).\n#\n# typeguard\'s own wording is accurate but assumes vocabulary a first-term\n# student does not have ("is not an instance of", "did not match any\n# element in the union"). Its messages look like:\n#\n# argument "y" (str) is not an instance of int\n# the return value (int) is not an instance of str\n# value assigned to total (str) is not an instance of int\n# item 2 of argument "nums" (list) is not an instance of int\n# value of key \'a\' of argument "x" (dict) is not an instance of int\n# argument "x" (str) did not match any element in the union: ...\n\n#: The module prefix a student did not write: a file - and its tests - run\n#: as `__main__`, so its classes arrive as `__main__.Dog`.\n_PLL_MAIN_PREFIX = "__main__."\n\n#: Names PLL\'s own classes report as, where the course calls them something\n#: else. A table row *is* a dict - `Row` subclasses it so a row still\n#: compares equal to the plain dict a test is written with - and `dict` is\n#: the type the course gives for a row. PLL\'s picture classes arrive already\n#: named `Image` (`_pll_course_type_name`).\n_PLL_COURSE_NAME_FOR = {"Row": "dict"}\n\n#: The methods that change a value in place and give back `None`, so that\n#: `result = result.append(w)` leaves `result` as `None` - and the error then\n#: appears at the `return` two lines later with nothing to connect them.\n_PLL_RETURNS_NONE = ("append", "extend", "insert", "remove", "sort", "reverse", "clear", "add", "discard", "update")\n\n\n@_pll_explains("TypeCheckError")\ndef _pll_explain_type_check(e):\n check = e.facts.get("check")\n frames = e.user_frames\n innermost = frames[-1] if frames else None\n # An argument is checked on entry to the callee, so the innermost frame\n # is the `def` line. The mistake is at the call, one frame out.\n blamed = frames[-2] if check and check.get("kind") == "argument" and len(frames) >= 2 else innermost\n headline, how_to_fix = _pll_type_check_wording(e, check, innermost)\n return {\n "id": "type-check",\n # Not a real Python exception name: "TypeCheckError" means nothing to\n # a student, and "TypeError" would name a different Python error.\n "errorType": "TypeMismatch",\n "headline": headline,\n "howToFix": how_to_fix,\n "fileName": blamed["file"] if blamed else (e.run[0] if e.run else None),\n "lineNumber": blamed["line"] if blamed else e.info["lineNumber"],\n "column": None,\n "nameToken": check.get("name") if check else None,\n }\n\n\nclass _PllCheck:\n """What every kind of explanation is built from: the check, and the\n phrases worked out from it once."""\n\n def __init__(self, e, check, innermost):\n check = dict(check or {"kind": "unknown", "name": None, "element": None, "actual": None, "expected": []})\n if check.get("element") is not None:\n check["element"] = _pll_describe_element(check["element"])\n self.e = e\n self.check = check\n self.kind = check.get("kind")\n self.name = check.get("name")\n self.element = check.get("element")\n self.actual = check.get("actual")\n self.expected = check.get("expected") or []\n # The line the check fired on, which for a return is the `return`\n # itself - or, when the function ran off its end, is not one.\n self.line = e.frame_text(innermost)\n self.called_by = _pll_library_caller(e.frames)\n function = innermost["function"] if innermost else None\n self.wanted = _pll_describe_types(self.expected)\n self.owner = "`%s`" % function if function else "this function"\n self.named = "`%s`" % self.name if self.name else "a value"\n self.convert = _pll_conversion_for(self.expected, self.actual)\n self.bool_note = _pll_bool_as_number_note(self.expected, self.actual)\n self.annotation = _pll_clean_type_name(self.expected[0]) if len(self.expected) == 1 else None\n\n\ndef _pll_type_check_wording(e, check, innermost):\n c = _PllCheck(e, check, innermost)\n said = _pll_class_given(c)\n if said is not None:\n return said\n explain = {\n "argument": _pll_tc_argument,\n "field": _pll_tc_field,\n "return": _pll_tc_return,\n "variable": _pll_tc_variable,\n }.get(c.kind, _pll_tc_unfamiliar)\n return explain(c)\n\n\ndef _pll_clean_type_name(type_name):\n # Anywhere, not just at the start: typeguard reports a class object as\n # `class __main__.ITunesSong`, so the prefix sits in the middle.\n cleaned = type_name.replace(_PLL_MAIN_PREFIX, "")\n return _PLL_COURSE_NAME_FOR.get(cleaned, cleaned)\n\n\ndef _pll_describe_type(type_name):\n """`int` -> "a whole number (`int`)", `Dog` -> "`Dog`"."""\n name = _pll_clean_type_name(type_name)\n if name in ("None", "NoneType"):\n return "`None`"\n friendly = _pll_type_words(name, article=True)\n return "%s (`%s`)" % (friendly, name) if friendly else "`%s`" % name\n\n\ndef _pll_describe_types(types):\n """["int", "NoneType"] -> "a whole number (`int`) or `None`"."""\n described = [_pll_describe_type(t) for t in types]\n if not described:\n return "a different type"\n if len(described) == 1:\n return described[0]\n return "%s or %s" % (", ".join(described[:-1]), described[-1])\n\n\ndef _pll_bool_as_number_note(expected, actual):\n """A `bool` rejected for `int`/`float` is PLL\'s rule, not Python\'s - and\n only the teaching levels\', the only ones that reject it - so say so\n rather than leaving the student to wonder why `True` is not a number."""\n if actual != "bool" or not any(t in ("int", "float") for t in expected):\n return None\n return (\n "At `#level beginner` and `#level intermediate`, `True` and `False` are not accepted as numbers, even"\n " though Python counts them as `1` and `0`."\n )\n\n\ndef _pll_field_being_replaced(line, name):\n """The field `line` reads from `name`, when the line replaces `name`\n itself: `ac = ac.balance + amt` reads `ac.balance` and assigns `ac`, and\n the field it reads is the one that was meant to change."""\n if line is None or name is None:\n return None\n escaped = _pll_src_re.escape(name)\n assigns = _pll_src_re.match(r"\\s*%s\\s*=(?!=)(.*)$" % escaped, line)\n if assigns is None:\n return None\n reads = _pll_src_re.search(r"\\b%s\\.([A-Za-z_]\\w*)\\b(?!\\s*\\()" % escaped, assigns.group(1))\n return reads.group(1) if reads else None\n\n\ndef _pll_with_article(described):\n """"a whole number (`int`)" stays as it is; "`Account`" becomes "an `Account`"."""\n if not described.startswith("`") or described == "`None`":\n return described\n return "%s %s" % ("an" if _pll_src_re.match(r"`[AEIOUaeiou]", described) else "a", described)\n\n\ndef _pll_conversion_for(expected, actual):\n """The conversion that would most likely fix this, if there is an obvious one."""\n if len(expected) != 1 or actual is None:\n return None\n to = expected[0]\n if to == "int" and actual in ("str", "float"):\n return "int"\n if to == "float" and actual == "str":\n return "float"\n if to == "str" and actual in ("int", "float", "bool"):\n return "str"\n return None\n\n\ndef _pll_members_phrase(element):\n """How to describe "all the things inside" for the failing element\'s kind."""\n if element.startswith("the value for key"):\n return "every value in"\n if element.startswith("key "):\n return "every key in"\n return "every item in"\n\n\ndef _pll_describe_element(element):\n """typeguard\'s words for a part of a collection, as something to point at."""\n if element.startswith("value of key "):\n return "the value for key " + _pll_plain_key(element[len("value of key ") :])\n if element.startswith("key "):\n return "key " + _pll_plain_key(element[len("key ") :])\n if element.startswith("["):\n return "one of the values"\n return element\n\n\ndef _pll_plain_key(key):\n """A key as typeguard quotes it - Python\'s `repr`, so `\'a\'` - in the\n double quotes PLL writes a string in, so that the key and the value\n beside it read alike. A key that is not a plain string, or whose quotes\n Python chose because of what is in it, is left as Python wrote it."""\n simple = _pll_src_re.fullmatch(r"\'([^\'\\"\\\\]*)\'", key)\n return \'"%s"\' % simple.group(1) if simple else key\n\n\ndef _pll_class_given(c):\n """The class itself where one made from it was meant, whatever was annotated."""\n found = _pll_src_re.match(r"class (.+)$", c.actual) if c.actual is not None else None\n if found is None or c.element is not None:\n return None\n given = _pll_clean_type_name(found.group(1))\n # `ITunesSong` rather than `ITunesSong(...)`. The two annotations read\n # almost identically - "expects s to be ITunesSong, but got class\n # ITunesSong" - so the difference has to be spelled out.\n if c.kind == "return":\n where = "%s returned" % c.owner\n elif c.kind == "variable":\n where = "%s was given" % c.named\n else:\n where = "%s was given" % c.owner\n return "%s the class `%s` itself, not one made from it." % (where, given), [\n "`%s` on its own is the blueprint; `%s(...)` makes one." % (given, given),\n "Add the brackets and the values for its fields: `%s(...)`." % given,\n ]\n\n\ndef _pll_tc_argument(c):\n """An argument that does not match its parameter\'s annotation."""\n if c.element:\n # What the element *is*, when Python could read it - "item 0 is\n # not" left the student to go and find out.\n value = c.e.facts.get("element_value")\n is_ = " is %s" % value if value is not None else " is not"\n return "%s expects %s %s to be %s, but %s%s." % (\n c.owner, _pll_members_phrase(c.element), c.named, c.wanted, c.element, is_\n ), [\n "Look at %s of the %s you passed for %s." % (c.element, c.actual or "value", c.named),\n "Every one has to match the annotation, not just the first.",\n ]\n got = ", but got %s" % _pll_describe_type(c.actual) if c.actual else ""\n headline = "%s expects %s to be %s%s." % (c.owner, c.named, c.wanted, got)\n supplied = c.called_by\n if supplied is not None:\n # Nothing on this line passed anything, so the advice is about the\n # `def` - and the generic "convert it" / "change the annotation"\n # bullets would only repeat that in two more ways.\n if supplied == "reactor":\n how_to_fix = [\n "The reactor calls %s with its state: the `init` value to start with, then whatever the handlers"\n " return. So %s is the reactor\'s state, not a value from this line." % (c.owner, c.named)\n ]\n else:\n taker = _PLL_FUNCTION_TAKERS.get(supplied)\n calls = taker["calls"] if taker else "`%s` calls %s for you" % (supplied, c.owner)\n how_to_fix = ["%s, so %s is whatever it hands over - not a value from this line." % (calls, c.named)]\n if c.annotation is not None and c.actual is not None:\n how_to_fix.append(\n "Annotate %s as `%s` in %s, or change what %s is given so it hands over %s."\n % (\n c.named,\n _pll_clean_type_name(c.actual),\n c.owner,\n "the reactor" if supplied == "reactor" else "`%s`" % supplied,\n c.wanted,\n )\n )\n if c.bool_note:\n how_to_fix.append(c.bool_note)\n return headline, how_to_fix\n how_to_fix = ["Check the value you passed for %s on this line." % c.named]\n if c.bool_note:\n how_to_fix.append(c.bool_note)\n if c.convert:\n how_to_fix.append("If it should be %s, convert it with `%s(...)`." % (c.wanted, c.convert))\n if c.annotation and c.actual:\n how_to_fix.append(\n "Or, if %s really should be %s, change its annotation in %s from `%s` to `%s`."\n % (c.named, _pll_describe_type(c.actual), c.owner, c.annotation, _pll_clean_type_name(c.actual))\n )\n return headline, how_to_fix\n\n\ndef _pll_tc_field(c):\n """A dataclass field given a value of the wrong type."""\n owner = c.check.get("owner") or "this class"\n value = c.check.get("value")\n # Python found another field this value fits, whose value fits here:\n # the two were given in each other\'s places, and converting one would\n # hide that.\n swapped = c.e.facts.get("swapped_with")\n if swapped is not None:\n return "The values for `%s` and `%s` of `%s` look swapped." % (c.name, swapped, owner), [\n "`%s` got `%s`, which fits `%s` - and the other way round."\n % (c.name, value if value is not None else "?", swapped),\n "Give the values in the order the fields are written in `class %s`." % owner,\n ]\n if value is not None:\n got = "`%s`" % value\n elif c.actual:\n got = _pll_describe_type(c.actual)\n else:\n got = "something else"\n how_to_fix = ["Check the values given to `%s(...)`, in the order its fields are written." % owner]\n if c.convert is not None:\n how_to_fix.append("If it should be %s, convert it with `%s(...)`." % (c.wanted, c.convert))\n if c.annotation is not None and c.actual is not None:\n how_to_fix.append(\n "Or change the field\'s annotation from `%s` to `%s`." % (c.annotation, _pll_clean_type_name(c.actual))\n )\n return "The `%s` field of `%s` should be %s, but got %s." % (c.name, owner, c.wanted, got), how_to_fix\n\n\ndef _pll_tc_return(c):\n """A return value that does not match the function\'s annotation."""\n # A `None` result has three quite different causes, and one message for\n # all of them describes the symptom rather than any of them.\n if c.actual in ("None", "NoneType"):\n return _pll_none_returned(c)\n if c.element:\n # One item of a collection: the collection is what was asked for,\n # so the item is the thing to change - not the annotation\'s container.\n value = c.e.facts.get("element_value")\n container = _pll_clean_type_name(c.actual) if c.actual else "value"\n how_to_fix = [\n "Look at %s of the %s this line returns: every one has to match the annotation, not just the first."\n % (c.element, container)\n ]\n element_type = c.e.facts.get("element_type")\n written = c.check.get("annotation")\n if written is not None and c.annotation is not None and element_type is not None:\n # Only where the annotation names the type once, so which one to\n # change is not a guess.\n pattern = r"\\b%s\\b" % _pll_src_re.escape(c.annotation)\n if len(_pll_src_re.findall(pattern, written)) == 1:\n replaced = _pll_src_re.sub(pattern, lambda _: element_type, written)\n how_to_fix.append(\n "Or, if that is what it should hold, change the annotation from `%s` to `%s`." % (written, replaced)\n )\n return "%s says it returns %s, with %s it %s, but %s%s." % (\n c.owner,\n "`%s`" % written if written is not None else "a %s" % container,\n _pll_members_phrase(c.element),\n c.wanted,\n c.element,\n " is %s" % value if value is not None else " is not",\n ), how_to_fix\n got = _pll_describe_type(c.actual) if c.actual else "something else"\n if len(c.expected) == 1 and c.expected[0] in ("None", "NoneType"):\n how_to_fix = ["Use a bare `return`, or drop the `return` entirely."]\n elif c.convert:\n how_to_fix = ["Convert the returned value with `%s(...)`." % c.convert]\n else:\n how_to_fix = ["Return %s from this line." % _pll_with_article(c.wanted)]\n if c.bool_note:\n how_to_fix.append(c.bool_note)\n if c.annotation and c.actual:\n how_to_fix.append(\n "Or change %s\'s return annotation from `%s` to `%s`."\n % (c.owner, c.annotation, _pll_clean_type_name(c.actual))\n )\n return "%s says it returns %s, but this line returns %s." % (c.owner, c.wanted, got), how_to_fix\n\n\ndef _pll_none_returned(c):\n """A function annotated to return something that produced `None`.\n\n Three causes, which need different things said about them: it ran off\n its end, with no `return` on the path taken; a `return` ran, and what it\n returned was already `None` - usually a name assigned from\n `.append(...)`; or a `match` fitted no case, so nothing happened and the\n function then ran off its end.\n """\n owner, wanted = c.owner, c.wanted\n annotate = "Or annotate the return type as `None` if %s is not meant to return anything." % owner\n returned = _pll_src_re.match(r"\\s*return\\s+(\\S.*?)\\s*$", c.line) if c.line is not None else None\n if returned is not None:\n expression = returned.group(1)\n assigned = (c.e.facts.get("assigned") or {}).get(expression)\n if (\n _pll_src_re.fullmatch(r"[A-Za-z_]\\w*", expression)\n and assigned is not None\n and "." in assigned["call"]\n and assigned["call"].rsplit(".", 1)[1] in _PLL_RETURNS_NONE\n ):\n method = assigned["call"].rsplit(".", 1)[1]\n return "%s returned `None`, because `%s` was set to the result of `.%s(...)` on line %d." % (\n owner, expression, method, assigned["line"]\n ), [\n "`.%s(...)` changes the list in place and gives back nothing." % method,\n "Call it on its own line - `%s.%s(...)` - rather than assigning its result back to `%s`."\n % (expression, method, expression),\n ]\n if expression == "None":\n # Written out: `return None`, where something was meant to come back.\n return "%s returns `None` on this line, but it should return %s." % (owner, wanted), [\n "Return %s here, or annotate %s as returning `None`." % (wanted, owner)\n ]\n return "%s should return %s, but `%s` is `None` here." % (owner, wanted, expression), [\n "This line did return - what it returned was nothing.",\n "Check where `%s` was last set." % expression,\n annotate,\n ]\n # No `return` ran at all. If the function ends in a `match`, no case\n # fitting is overwhelmingly the reason.\n returned_none = c.e.facts.get("returned_none") or {}\n match = returned_none.get("match")\n if match is not None:\n how_to_fix = []\n # When what is matched is annotated with a union, the variants with\n # no `case` can be named outright.\n missing = match.get("uncovered") or []\n if missing:\n how_to_fix.append("There is no `case` for %s." % " or ".join("`%s`" % name for name in missing))\n else:\n how_to_fix.append("Every possible value of `%s` needs a `case`." % match["subject"])\n if match.get("has_list") and not any(p.strip() == "[]" for p in match.get("patterns") or []):\n how_to_fix.append("The empty list, `case []:`, is the one most often left out.")\n for pattern in match.get("fixed_length") or []:\n parts = [part.strip() for part in pattern[1:-1].split(",")]\n how_to_fix.append(\n "`case %s:` matches a list of exactly %d items; for a first and a rest, write `[%s, *%s]`."\n % (pattern, len(parts), ", ".join(parts[:-1]), parts[-1])\n )\n # Deliberately no "annotate it as `None`" here. A `match` that fitted\n # nothing is a missing case; annotating the return type as `None`\n # would silence the symptom and keep the bug.\n return "No `case` in %s fitted `%s`, so nothing was returned." % (owner, match["subject"]), how_to_fix\n headline = "%s should return %s, but it finished without returning a value." % (owner, wanted)\n # A branch that ends in `print` is the single commonest version of this,\n # and it shows the right answer on screen - so the function looks fine.\n # Once found it is the cause, so the general advice is left out.\n printed = returned_none.get("printed")\n if printed is not None:\n expression = printed.get("expression")\n return headline, [\n "Line %d ends its branch with `print`: did you mean %s"\n % (printed["line"], "`return %s`?" % expression if expression is not None else "`return` instead of `print`?"),\n "`print` shows a value on screen; `return` hands it back to whatever called the function.",\n ]\n return headline, [\n "Make sure every path through the function reaches a `return`.",\n "An `if` with no `else` falls off the end, and Python then returns `None`.",\n annotate,\n ]\n\n\ndef _pll_tc_variable(c):\n """An annotated variable assigned a value of the wrong type."""\n if c.element:\n return "%s is annotated so %s it is %s, but %s is not." % (\n c.named, _pll_members_phrase(c.element), c.wanted, c.element\n ), ["Look at %s of the value assigned to %s." % (c.element, c.named)]\n got = _pll_describe_type(c.actual) if c.actual else "a different type"\n headline = "%s is annotated as %s, but %s was assigned here." % (c.named, c.wanted, got)\n # `ac = ac.balance + amt`, where `ac.balance = ...` was meant: the line\n # reads one of the variable\'s own fields, so that field is the target.\n field = _pll_field_being_replaced(c.line, c.name)\n if field is not None and c.name is not None:\n rhs = _pll_src_re.search(r"=\\s*(.+)$", c.line or "")\n rhs = rhs.group(1).strip() if rhs else "..."\n return headline, [\n "To change the `%s` field of %s, assign to the field: `%s.%s = %s`." % (field, c.named, c.name, field, rhs),\n "`%s = ...` replaces the whole %s, field and all."\n % (c.name, "`%s`" % c.annotation if c.annotation is not None else "value"),\n ]\n # "Assign `Account` to `ac`" read as assigning the class itself, so a\n # class gets an article, the way a gloss like "a whole number" has one.\n how_to_fix = ["Assign %s to %s." % (_pll_with_article(c.wanted), c.named)]\n if c.bool_note:\n how_to_fix.append(c.bool_note)\n if c.convert:\n how_to_fix.append("You can convert the value with `%s(...)`." % c.convert)\n if c.annotation and c.actual:\n how_to_fix.append(\n "Or change %s\'s annotation from `%s` to `%s`." % (c.named, c.annotation, _pll_clean_type_name(c.actual))\n )\n return headline, how_to_fix\n\n\ndef _pll_tc_unfamiliar(c):\n """Unfamiliar wording: still say what kind of problem this is, and pass\n typeguard\'s own sentence through rather than inventing detail."""\n return "A value does not match the type annotation written for it.", [\n _pll_src_re.split(r"\\r?\\n", c.e.message)[0].strip(),\n "Either pass a value of the annotated type, or change the annotation.",\n ]\n';
222
222
 
223
223
  // src/python/explain/stock.py
224
- var stock_default = '# PLL\'s wording for the errors Python words badly for a beginner.\n#\n# Each rule reads Python\'s message and, where it needs more, the student\'s\n# line and what Python found out about their code: Python says `pen_cost()\n# missing 1 required positional argument: \'message\'` without saying that\n# `pen_cost` takes two, and the definition Python found does.\n#\n# A rule returns None when on a closer look it cannot say anything better\n# than Python did, and the message is then shown as Python wrote it (the\n# catch-all, in finding.py). That is the right default - a confident wrong\n# explanation is worse than a terse right one - so rules match narrowly and\n# give up readily. Several do nothing but delete Python\'s internal names\n# (`__init__`, `types.UnionType`, `Table.`), which is worth a rule on its\n# own: those names send a student looking for something they never wrote.\n\n#: `(types, pattern, explain)` in the order they are tried.\n_PLL_STOCK_RULES = []\n\n\ndef _pll_stock(types, pattern):\n """Register a rewording of Python\'s message `pattern` for errors of\n `types`: a function of the match and the error, giving `(headline,\n how_to_fix)`, or None to leave the error to the rules after it."""\n\n def register(explain):\n _PLL_STOCK_RULES.append((types.split(), _pll_src_re.compile(pattern), explain))\n return explain\n\n return register\n\n\n@_pll_explains(\n "TypeError", "AttributeError", "ValueError", "IndexError", "RecursionError", "KeyError", "ModuleNotFoundError"\n)\ndef _pll_explain_stock(e):\n for types, pattern, explain in _PLL_STOCK_RULES:\n if e.type not in types:\n continue\n found = pattern.search(e.message)\n said = explain(found, e) if found else None\n if said is not None:\n return ("stock-message",) + said\n return None\n\n\n# -- shared wording -----------------------------------------------------------\n\n#: Why a value is `None`, which is almost never obvious to a beginner.\n_PLL_WHY_NONE = [\n "A function with no `return` gives back `None`.",\n "A method that changes something in place - `append`, `sort` - also gives back `None`.",\n]\n\n#: Everything a function can be handed to: a library function, or a handler keyword.\n_PLL_CALLS_YOUR_FUNCTION = tuple(_PLL_FUNCTION_TAKERS) + _PLL_HANDLER_KEYWORDS\n\n_PLL_COMPARISONS = ("<", ">", "<=", ">=", "==", "!=")\n\n\ndef _pll_last_segment(type_name):\n return type_name.split(".")[-1]\n\n\ndef _pll_friendly(type_name):\n """`str` -> "string". A class the student wrote keeps its own name."""\n name = _pll_last_segment(type_name)\n words = _pll_type_words(name)\n return words if words is not None else "`%s`" % name\n\n\ndef _pll_list_names(names):\n """`a`, `b` and `c` - for listing names in prose."""\n return _pll_listed(["`%s`" % name for name in names])\n\n\ndef _pll_listed(items, joined="and"):\n """a, b and c - or "or" - for listing anything in prose."""\n if len(items) <= 1:\n return "".join(items)\n return "%s %s %s" % (", ".join(items[:-1]), joined, items[-1])\n\n\ndef _pll_takes_clause(names, count):\n """"takes 2 arguments (`a`, `b`)", or "takes 2 arguments" if unknown."""\n if names:\n return "takes %s (%s)" % (_pll_plural(len(names), "argument"), _pll_list_names(names))\n return "takes %s" % _pll_plural(count, "argument")\n\n\ndef _pll_needs_more(name, missing, message):\n absent = _pll_src_re.findall(r"\'(\\w+)\'", message)\n return "`%s` needs %s%s." % (name, _pll_plural(missing, "more argument"), ": " + _pll_list_names(absent) if absent else "")\n\n\ndef _pll_library_hint(name):\n return list(_PLL_LIBRARY_HINTS.get(name, []))\n\n\n# -- what Python found the names to be ----------------------------------------\n\n\ndef _pll_defined_as(e, name):\n return (e.facts.get("definitions") or {}).get(name)\n\n\ndef _pll_parameters_of(e, name, which):\n """A function\'s parameters - those it requires, or every positional one."""\n found = _pll_defined_as(e, name)\n return found[which] if found and found["kind"] == "function" else None\n\n\ndef _pll_students_class(e, name):\n found = _pll_defined_as(e, name)\n return bool(found and found["kind"] == "class" and found["students"])\n\n\ndef _pll_fields_of(e, name):\n """The fields of one of the student\'s classes, or None when it has none."""\n found = _pll_defined_as(e, name)\n return found["fields"] if found and found["kind"] == "class" and found["fields"] else None\n\n\ndef _pll_union_members_of(e, name):\n found = _pll_defined_as(e, name)\n return found["members"] if found and found["kind"] == "union" and len(found["members"]) > 1 else None\n\n\n# -- reading the student\'s line -----------------------------------------------\n#\n# Several rules want "the expression next to this `+`" or "the argument of\n# this `filter(`", written exactly as the student wrote it. A regex could\n# not see past a nested call - `"Total: " + add_shipping(pen_cost(10,\n# "bravo"))` was answered with `str(add_shipping)`, converting the function\n# rather than its result. Tokens let brackets balance and strings be\n# skipped, which is all these rules need.\n\n#: The calls on a line that are almost never what produced the offending\n#: value, so they are only named when nothing else is there.\n_PLL_WRAPPERS = frozenset(\n ("print", "str", "int", "float", "bool", "len", "list", "sorted", "round", "abs", "sum", "min", "max", "type", "repr")\n)\n\n\nclass _PllToken:\n __slots__ = ("kind", "text", "start", "end")\n\n def __init__(self, kind, text, start, end):\n self.kind, self.text, self.start, self.end = kind, text, start, end\n\n\ndef _pll_line_tokens(line):\n """One line of Python, split into enough tokens to find an operand."""\n tokens = []\n i, n = 0, len(line)\n while i < n:\n c = line[i]\n if c == "#":\n break\n if c.isspace():\n i += 1\n continue\n start = i\n if c in "\\"\'":\n i += 1\n while i < n and line[i] != c:\n i += 2 if line[i] == "\\\\" else 1\n i = min(i + 1, n)\n tokens.append(_PllToken("str", line[start:i], start, i))\n elif c.isascii() and (c.isalpha() or c == "_"):\n while i < n and line[i].isascii() and (line[i].isalnum() or line[i] == "_"):\n i += 1\n tokens.append(_PllToken("name", line[start:i], start, i))\n elif c in "0123456789":\n while i < n and line[i] in "0123456789.":\n i += 1\n tokens.append(_PllToken("num", line[start:i], start, i))\n elif c in "([{":\n i += 1\n tokens.append(_PllToken("open", c, start, i))\n elif c in ")]}":\n i += 1\n tokens.append(_PllToken("close", c, start, i))\n else:\n two = line[i : i + 2]\n op = two if two in ("<=", ">=", "==", "!=", "**", "//") else c\n i += len(op)\n tokens.append(_PllToken("op", op, start, i))\n return tokens\n\n\ndef _pll_token(tokens, at):\n """The token at `at`, or None past either end."""\n return tokens[at] if 0 <= at < len(tokens) else None\n\n\ndef _pll_text_at(tokens, at):\n token = _pll_token(tokens, at)\n return token.text if token else None\n\n\ndef _pll_closing(tokens, open_at):\n """The index of the bracket closing the one opened at `open_at`, or -1."""\n depth = 0\n for i in range(open_at, len(tokens)):\n if tokens[i].kind == "open":\n depth += 1\n if tokens[i].kind == "close":\n depth -= 1\n if depth == 0:\n return i\n return -1\n\n\ndef _pll_operand_from(line, tokens, at):\n """The expression starting at token `at`: a name, its attributes and calls."""\n if at >= len(tokens) or tokens[at].kind not in ("name", "num"):\n return None\n last, i = at, at + 1\n while i < len(tokens):\n following = _pll_token(tokens, i + 1)\n if tokens[i].text == "." and following is not None and following.kind == "name":\n last = i + 1\n i += 2\n elif tokens[i].kind == "open" and tokens[i].text != "{":\n shut = _pll_closing(tokens, i)\n if shut < 0:\n return None\n last = shut\n i = shut + 1\n else:\n break\n return line[tokens[at].start : tokens[last].end]\n\n\ndef _pll_operand_before(line, tokens, at):\n """The expression ending at token `at`, read backwards to where it starts."""\n if not 0 <= at < len(tokens):\n return None\n first = i = at\n while i >= 0:\n if tokens[i].kind == "close":\n # Walk back to the matching open bracket.\n depth, j = 0, i\n while j >= 0:\n if tokens[j].kind == "close":\n depth += 1\n if tokens[j].kind == "open":\n depth -= 1\n if depth == 0:\n break\n j -= 1\n if j < 0:\n return None\n first = j\n i = j - 1\n continue\n if tokens[i].kind in ("name", "num"):\n first = i\n before = _pll_token(tokens, i - 2)\n if _pll_text_at(tokens, i - 1) == "." and before is not None and before.kind == "name":\n i -= 2\n continue\n break\n if i == at:\n return None\n break\n if tokens[first].kind not in ("name", "num"):\n return None\n return line[tokens[first].start : tokens[at].end]\n\n\ndef _pll_concat_operands(line):\n """A string literal and the operand `+` joins it to, as written:\n `(text, value, text_first)`, or None."""\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n if token.kind != "str":\n continue\n if _pll_text_at(tokens, i + 1) == "+":\n value = _pll_operand_from(line, tokens, i + 2)\n if value is not None:\n return token.text, value, True\n if _pll_text_at(tokens, i - 1) == "+" and i >= 2:\n value = _pll_operand_before(line, tokens, i - 2)\n if value is not None:\n return token.text, value, False\n return None\n\n\ndef _pll_attribute_receiver(line, attr):\n """The expression `.attr` is read from on `line`, as written."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i in range(2, len(tokens)):\n if tokens[i].text == attr and tokens[i - 1].text == ".":\n return _pll_operand_before(line, tokens, i - 2)\n return None\n\n\ndef _pll_subscript_receiver(line):\n """The expression subscripted on `line` - the `xs` of `xs[0]`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i in range(1, len(tokens)):\n before = tokens[i - 1]\n if tokens[i].text == "[" and (before.kind == "name" or before.text in (")", "]")):\n return _pll_operand_before(line, tokens, i - 1)\n return None\n\n\ndef _pll_looped_over(line):\n """What `for ... in` loops over on `line`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n if not tokens or tokens[0].text != "for":\n return None\n at = next((i for i, token in enumerate(tokens) if token.text == "in"), -1)\n return None if at < 0 else _pll_operand_from(line, tokens, at + 1)\n\n\ndef _pll_operand_beside(line, operator, side):\n """The operand on one side of the first `operator` on `line`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n at = next((i for i, token in enumerate(tokens) if token.kind == "op" and token.text == operator), -1)\n if at < 0:\n return None\n return _pll_operand_before(line, tokens, at - 1) if side == "left" else _pll_operand_from(line, tokens, at + 1)\n\n\ndef _pll_call_on(line):\n """The call on `line` whose result the error is probably about: not a\n wrapper like `print`, unless it is the only call there."""\n names = _pll_src_re.findall(r"([A-Za-z_][\\w.]*)\\s*\\(", line or "")\n if not names:\n return None\n return next((name for name in names if name not in _PLL_WRAPPERS), names[0])\n\n\ndef _pll_typed_operand(line, facts):\n """A `+` operand that holds what `input()` returned, and the sum with it\n converted: `age + 1` becomes `int(age) + 1`."""\n tokens = _pll_line_tokens(line)\n assigned = facts.get("assigned") or {}\n for i, token in enumerate(tokens):\n if token.text != "+":\n continue\n for at, side in ((i - 1, "left"), (i + 1, "right")):\n operand = _pll_token(tokens, at)\n if (\n operand is None\n or operand.kind != "name"\n or _pll_text_at(tokens, at + 1) == "("\n or _pll_text_at(tokens, at - 1) == "."\n ):\n continue\n if (assigned.get(operand.text) or {}).get("call") != "input":\n continue\n left = "int(%s)" % operand.text if side == "left" else _pll_operand_before(line, tokens, i - 1)\n right = "int(%s)" % operand.text if side == "right" else _pll_operand_from(line, tokens, i + 1)\n if left is None or right is None:\n continue\n return operand.text, "%s + %s" % (left, right)\n return None\n\n\ndef _pll_why_none(e, receiver, unknown="This value is `None`"):\n """Where the `None` came from, said about the student\'s own code, as\n `(subject, how_to_fix)`.\n\n `receiver` is what the line did something to - the `x` of `x.total`,\n the thing subscripted, looped over or added. If it is a call, that call\n gave back `None`. If it is a name, the call it was set from did, and\n saying which is the useful part: blaming the first call on the line\n named `print` in `print(x.total)`, where `print` only wraps the value.\n """\n call = _pll_src_re.match(r"([A-Za-z_][\\w.]*)\\s*\\(", receiver) if receiver is not None else None\n if call:\n return "`%s(...)` gave back `None`" % call.group(1), _PLL_WHY_NONE\n if receiver is not None and _pll_src_re.fullmatch(r"[A-Za-z_][\\w.]*", receiver):\n subject = "`%s` is `None`" % receiver\n # A parameter got its `None` from whoever called the function.\n frames = e.user_frames\n inside = frames[-1]["function"] if frames else None\n if inside is not None and receiver in (frames[-1]["parameters"] or []):\n caller = " on line %s" % frames[-2]["line"] if len(frames) >= 2 else ""\n return subject, [\n "`%s` is a parameter of `%s`, so the call%s gave it `None`." % (receiver, inside, caller)\n ] + _PLL_WHY_NONE\n set_from = (e.facts.get("assigned") or {}).get(receiver)\n if set_from is None:\n return subject, _PLL_WHY_NONE\n return subject, [\n "`%s` was set from `%s(...)` on line %s, which gave back `None`." % (receiver, set_from["call"], set_from["line"])\n ] + _PLL_WHY_NONE\n # Nothing on the line to point at: name a call, but never a wrapper\n # like `print`, which is not where the value came from.\n named = next(\n (name for name in _pll_src_re.findall(r"([A-Za-z_][\\w.]*)\\s*\\(", e.text or "") if name not in _PLL_WRAPPERS),\n None,\n )\n return ("`%s(...)` gave back `None`" % named if named is not None else unknown), _PLL_WHY_NONE\n\n\ndef _pll_without_joining_space(literal, before):\n """The literal with the space `print` will add for it taken out:\n `print("Next year you will be ", age)` prints two spaces."""\n quote, inner = literal[0], literal[1:-1]\n trimmed = _pll_src_re.sub(r" $", "", inner, count=1) if before else _pll_src_re.sub(r"^ ", "", inner, count=1)\n return quote + trimmed + quote\n\n\n# -- the rules, in the order they are tried -----------------------------------\n\n\n@_pll_stock("ModuleNotFoundError", r"^No module named")\ndef _pll_missing_module(m, e):\n """`No module named \'flask\'`, said as why: PLL\'s Python is Pyodide\'s,\n whose packages are downloaded before a program runs, from the imports\n PLL can read in it."""\n module = e.facts.get("module")\n if module is None:\n return None\n name, kind = module["name"], module["kind"]\n if kind == "leftOut":\n return "`%s.py` is next to your program, but it was not loaded: %s." % (name, module.get("why")), [\n "A file that was not loaded cannot be imported."\n ]\n if kind == "notLoaded":\n return "`%s` was not loaded before your program ran." % name, [\n "PLL downloads `%s` the first time a program imports it, so this needs the internet once; after that it"\n " is kept. Check the connection, and run the program again." % module.get("package"),\n "PLL loads a package from the `import` lines it reads before the program runs: if this import is made"\n " another way, with a name worked out as it runs, add `import %s` near the top of the file." % name,\n ]\n if kind == "missing":\n return "There is no module called `%s` here." % name, [\n "PLL runs Python with Pyodide, which has many packages - numpy, pandas, matplotlib, requests and more -"\n " but not `%s`." % name,\n "If `%s` is a file of your own, put `%s.py` next to your program." % (name, name),\n ]\n return None\n\n\n@_pll_stock("TypeError", r"Table|^a table ")\ndef _pll_builtin_on_table(m, e):\n """`sum(people, "age")`: Python\'s own `sum`, `min` or `max` given a table\n and a column. A table does that itself."""\n call = _pll_src_re.search(\n r"""\\b(sum|min|max|len|sorted)\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*,\\s*("[^"]*"|\'[^\']*\')\\s*\\)""", e.text or ""\n )\n if call is None:\n return None\n fn, table, column = call.groups()\n method = "length" if fn == "len" else "order_by" if fn == "sorted" else fn\n return "`%s(%s, %s)` is Python\'s own `%s`, which knows nothing of tables." % (fn, table, column, fn), [\n "A table does this itself: `%s.%s(%s)`." % (table, method, "" if method == "length" else column)\n ]\n\n\n@_pll_stock("TypeError", r"not supported between instances of|unsupported operand type")\ndef _pll_condition_not_function(m, e):\n """`filter("riders" < 1000)` - a condition where a function belongs.\n\n The comparison is worked out before `filter` is reached, so what Python\n reports is the comparison - accurately, and beside the point. `filter`\n calls a function on each row, and the condition has to be its body.\n """\n line = e.text\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n taker = _PLL_FUNCTION_TAKERS.get(token.text)\n if taker is None or "each" not in taker or token.kind != "name" or _pll_text_at(tokens, i + 1) != "(":\n continue\n shut = _pll_closing(tokens, i + 1)\n if shut < 0:\n continue\n inside = tokens[i + 2 : shut]\n # A comparison at the argument\'s own level, and no `lambda` - which\n # would make it a function after all, anywhere in the argument.\n depth, compares, is_function = 0, False, False\n for part in inside:\n if part.kind == "open":\n depth += 1\n if part.kind == "close":\n depth -= 1\n if depth == 0 and part.text in _PLL_COMPARISONS:\n compares = True\n if part.text == "lambda":\n is_function = True\n if not compares or is_function:\n continue\n name = token.text\n receiver = _pll_text_at(tokens, i - 2) if _pll_text_at(tokens, i - 1) == "." else None\n # A column name compared directly - `"riders" < 1000` - is the text,\n # not the column; inside the function it is `r["riders"]`.\n condition, named = "", None\n cursor = tokens[i + 2].start if i + 2 < len(tokens) else 0\n for k, part in enumerate(inside):\n next_op = inside[k + 1].text if k + 1 < len(inside) else None\n prev_op = inside[k - 1].text if k >= 1 else None\n condition += line[cursor : part.start]\n if part.kind == "str" and name != "transform_column" and (\n next_op in _PLL_COMPARISONS or prev_op in _PLL_COMPARISONS\n ):\n condition += "r[%s]" % part.text\n named = named if named is not None else part.text\n else:\n condition += part.text\n cursor = part.end\n condition = condition.strip()\n call = "%s.%s(keep)" % (receiver, name) if receiver is not None else "%s(keep)" % name\n how_to_fix = [\n "`%s` calls a function on %s and %s, so the condition belongs inside a function. Here it was worked out"\n " first - which is what the error is about." % (name, taker["each"], taker["keeps"]),\n "Write `def keep(%s) -> bool:` with `return %s` as its body, then `%s`." % (taker["param"], condition, call),\n ]\n if named is not None:\n how_to_fix.append("On its own, `%s` is just text. The value in that column of a row is `r[%s]`." % (named, named))\n return "`%s` needs a function, but this gives it a condition." % name, how_to_fix\n return None\n\n\n@_pll_stock("ValueError", r"^reactor\'s `(\\w+)` has to be a function")\ndef _pll_handler_called(m, e):\n """A reactor\'s handler called rather than named: `to_draw=draw_dog(0)`\n has already run `draw_dog` and handed over its picture, so Python\n cannot say which function it was. The student\'s line still says."""\n line = e.text\n if line is None:\n return None\n handler = m.group(1)\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n if token.text != handler or _pll_text_at(tokens, i + 1) != "=":\n continue\n called = _pll_operand_from(line, tokens, i + 2)\n after = _pll_token(tokens, i + 2)\n fn = after.text if after is not None and after.kind == "name" else None\n if called is None or fn is None or called == fn:\n continue\n return "`%s` has to be given the function itself, not what calling it gives back." % handler, [\n "Write `%s=%s`, not `%s=%s`." % (handler, fn, handler, called),\n "The reactor calls `%s` itself, with its state, every time it needs a picture." % fn,\n ]\n return None\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) missing \\d+ required positional argument")\ndef _pll_function_called_not_passed(m, e):\n """`filter(below_1k())` - the function called instead of passed.\n\n The call happens before `filter` is entered, so what Python reports is\n `below_1k() missing 1 required positional argument: \'r\'`, pointing at\n the `def` and mentioning neither `filter` nor the parentheses. Only the\n student\'s line says which it was.\n """\n line = e.text\n if line is None:\n return None\n name = m.group(1)\n # The empty call has to be *given to* the taker, not merely share a line\n # with it: `filter(below_1k())` or `to_draw=draw()`.\n taker = next(\n (\n candidate\n for candidate in _PLL_CALLS_YOUR_FUNCTION\n if _pll_src_re.search(r"\\b%s\\b\\s*(?:\\(|=)\\s*(?:[^()]*,\\s*)?%s\\s*\\(\\s*\\)" % (candidate, name), line)\n ),\n None,\n )\n if taker is None:\n return None\n written = "%s=%s" % (taker, name) if taker in _PLL_HANDLER_KEYWORDS else "%s(%s)" % (taker, name)\n return "`%s` calls `%s` for you, so it needs the function itself." % (taker, name), [\n "Leave the brackets off: `%s`." % written,\n "`%s()` calls it right here, with nothing to work on." % name,\n ]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) takes no arguments")\ndef _pll_missing_dataclass(m, e):\n """`ITunesSong() takes no arguments`: a class with annotated fields and\n no `@dataclass`. Exactly true and completely unhelpful: the student\n wrote three fields and is told the class takes nothing."""\n class_name = m.group(1)\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return None\n return "`%s` lists fields (%s) but has no `@dataclass`, so it takes no arguments." % (\n class_name, _pll_list_names(fields)\n ), [\n "Write `@dataclass` on the line above `class %s`." % class_name,\n "Add `from dataclasses import dataclass` at the top of the file if it is not there.",\n ]\n\n\n@_pll_stock(\n "TypeError",\n r"^(\\w+)\\.__init__\\(\\) (?:missing (\\d+) required positional argument|takes \\d+ positional arguments? but \\d+ (?:was|were) given)",\n)\ndef _pll_dataclass_arity(m, e):\n """A dataclass constructed with the wrong number of values. `__init__`\n is the thing to get rid of: the student wrote `ITunesSong(...)` and\n never wrote an `__init__`."""\n class_name = m.group(1)\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return "`%s` was given the wrong number of values." % class_name, [\n "Give one value for each field of `class %s`, in order." % class_name\n ]\n # Python counts `self`, so "takes 3 but 4 were given" is about 2 fields\n # and 3 values. Both counts are recomputed from the fields.\n given = _pll_src_re.search(r"takes \\d+ positional arguments? but (\\d+)", e.message)\n got = int(given.group(1)) - 1 if given else max(len(fields) - int(m.group(2)), 0)\n return "`%s` needs %s (%s), but got %d." % (\n class_name, _pll_plural(len(fields), "value"), _pll_list_names(fields), got\n ), ["Give one value for each field, in the order they are written in `class %s`." % class_name]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) missing (\\d+) required positional argument")\ndef _pll_too_few_arguments(m, e):\n """`f() missing 2 required positional arguments: \'a\' and \'b\'`"""\n name, missing = m.group(1), int(m.group(2))\n # A test function takes no parameters, so pytest has nothing to pass and\n # the test never runs.\n if name.startswith("test_"):\n # Python names the ones missing - all of them, called with nothing.\n named = _pll_src_re.findall(r"\'([^\']+)\'", e.message.split(":", 1)[1]) if ":" in e.message else []\n return "`%s` is a test, so it cannot take any parameters." % name, [\n _pll_test_fix(name, named or _pll_parameters_of(e, name, "required") or [], False, ())\n ]\n check = _pll_arity_check(e, name, "Check the call on this line - one value is needed for each parameter.")\n # Wherever it was defined: the student\'s file, another of theirs, or\n # PLL\'s own library.\n names = _pll_parameters_of(e, name, "required")\n if names is None:\n # An imported function from somewhere else: say only what is\n # missing, which Python did name, rather than inventing a total.\n return _pll_needs_more(name, missing, e.message), [check]\n return "`%s` %s, but got %d." % (name, _pll_takes_clause(names, len(names)), max(len(names) - missing, 0)), [\n check\n ] + _pll_library_hint(name)\n\n\ndef _pll_malformed_test_finding(name, parameters, filename, line, unused=()):\n """A `test_*` function with parameters, which the tests do not run: a\n test is called with nothing, so there would be no values for them. Said\n apart from the tests that ran - it is a mistake in how the test is\n written, not a test that failed. `name` is as the report lists it, a\n method\'s as `TestThings::test_one`; `unused` are the parameters its body\n never reads."""\n function = name.split("::")[-1]\n takes = (\n "a parameter, %s," % _pll_list_names(parameters)\n if len(parameters) == 1\n else "parameters - %s -" % _pll_list_names(parameters)\n )\n return {\n "id": "malformed-test",\n "errorType": "MalformedTest",\n "headline": "`%s` takes %s so it cannot be run as a test." % (function, takes),\n "howToFix": [\n _pll_test_fix(function, parameters, function != name, unused),\n "If it is not a test but a helper your tests use, give it a name that does not start with `test_`.",\n ],\n "fileName": filename,\n "lineNumber": line,\n "column": None,\n "nameToken": function,\n "severity": "error",\n "origin": "runtime",\n }\n\n\ndef _pll_test_fix(function, parameters, method, unused):\n """How a test written with `parameters` is written instead: with empty\n brackets - `self` only, for a method - and examples in its asserts where\n it used the parameters; or, when it used none of them, only the\n brackets. Unknown `parameters` are said as "its parameters"."""\n written = "def %s(self):" % function if method else "def %s():" % function\n if parameters and all(p in unused for p in parameters):\n return "Take %s out of the brackets - `%s` - since the test does not use %s." % (\n _pll_list_names(parameters), written, "it" if len(parameters) == 1 else "them"\n )\n used = [p for p in parameters if p not in unused]\n return "Write `%s`, with %s in the brackets, and use example values in its `assert`s instead of %s." % (\n written, "only `self`" if method else "nothing", _pll_list_names(used) if used else "its parameters"\n )\n\n\ndef _pll_arity_check(e, name, otherwise):\n """What to look at for a wrong number of values: the call on this line,\n or - when a table method made the call - the function\'s parameters."""\n taker = _PLL_FUNCTION_TAKERS.get(_pll_library_caller(e.frames))\n if taker is None or "param" not in taker:\n return otherwise\n return "%s, so give it one parameter: `def %s(%s):`." % (taker["calls"], name, taker["param"])\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) takes (\\d+) positional arguments? but (\\d+) (?:was|were) given")\ndef _pll_too_many_arguments(m, e):\n """`f() takes 1 positional argument but 2 were given`"""\n name, takes, given = m.group(1), int(m.group(2)), int(m.group(3))\n # Too many, so the limit is every parameter there is, optional included.\n names = _pll_parameters_of(e, name, "parameters")\n extra = given - (len(names) if names is not None else takes)\n return "`%s` %s, but got %d." % (name, _pll_takes_clause(names, takes), given), [\n _pll_arity_check(\n e,\n name,\n "Check the call on this line - there are %d values too many." % extra\n if extra > 1\n else "Check the call on this line - there is one value too many.",\n )\n ] + _pll_library_hint(name)\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\.(\\w+)\\(\\) missing (\\d+) required positional argument")\ndef _pll_method_arity(m, e):\n """A method called with too few arguments. Python names the class\n (`Table.scatter_plot()`), which the student may never have typed."""\n owner, method, missing = m.group(1), m.group(2), int(m.group(3))\n if method == "__init__":\n return None\n required = _pll_parameters_of(e, "%s.%s" % (owner, method), "required")\n if required is None:\n return _pll_needs_more(method, missing, e.message), []\n return "`%s` %s, but got %d." % (\n method, _pll_takes_clause(required, len(required)), max(len(required) - missing, 0)\n ), []\n\n\n@_pll_stock("AttributeError", r"^type object \'(\\w+)\' has no attribute \'(\\w+)\'")\ndef _pll_attribute_on_class(m, e):\n """The class used where one of its values was meant - `title(ITunesSong)`\n rather than `title(song)`. "type object" means nothing to a student."""\n class_name, asked = m.groups()\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return None\n return "`%s` on its own is the class, not one made from it, so it has no `%s`." % (class_name, asked), [\n "`%s(...)` makes one, with a value for each field (%s)." % (class_name, _pll_list_names(fields)),\n "A field belongs to the value, not to the class it was made from.",\n ]\n\n\n@_pll_stock("AttributeError", r"^\'([\\w.]+)\' object has no attribute \'(\\w+)\'")\ndef _pll_no_such_attribute(m, e):\n """`\'ITunesSong\' object has no attribute \'yaer\'`"""\n type_name, asked = m.groups()\n name = _pll_last_segment(type_name)\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_attribute_receiver(e.text, asked))\n return "%s, so it has no `%s`." % (subject, asked), why\n fields = _pll_fields_of(e, name)\n if fields is None:\n # A class of theirs with no fields - the empty end of a recursive\n # type, like `NoInfo` - has nothing to get, so the value needs\n # checking for before its fields are used.\n if _pll_students_class(e, name):\n return "`%s` has no fields at all, so it has no `%s`." % (name, asked), [\n "A value that might be a `%s` needs a `case %s():` before `.%s` is used." % (name, name, asked)\n ]\n return "A %s has no `%s`." % (_pll_friendly(type_name), asked), []\n suggestion = _pll_closest_name(asked, fields)\n return "`%s` has no field `%s` (its fields are %s)." % (name, asked, _pll_list_names(fields)), (\n ["Did you mean `%s`?" % suggestion] if suggestion is not None else []\n )\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not subscriptable")\ndef _pll_not_subscriptable(m, e):\n """`\'ITunesSong\' object is not subscriptable`"""\n type_name = m.group(1)\n name = _pll_last_segment(type_name)\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_subscript_receiver(e.text))\n return "%s, so square brackets cannot get anything out of it." % subject, why\n method = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\.(\\w+)\\s*\\[\\s*([^\\]]*)\\]", e.text or "")\n if name == "method" and method is not None:\n # `people.rows[0]`, `people.row[0]`: a method used as if it were a list.\n holder, called, inside = method.groups()\n return "`%s.%s` is a method, so it is called with round brackets, not indexed with square ones." % (\n holder, called\n ), [\n "Write `%s.row(%s)`: the row number goes in the round brackets." % (holder, inside)\n if called == "row"\n else "Call it first: `%s.%s()[%s]`." % (holder, called, inside)\n ]\n fields = _pll_fields_of(e, name)\n if fields is None:\n # `r["age"]` in a function `transform_column` calls: it is given one\n # value of the column, not the row.\n how_to_fix = []\n if _pll_library_caller(e.frames) == "transform_column":\n how_to_fix.append(\n "%s, so what your function is given is already the value - there is nothing to take out of it."\n % _PLL_FUNCTION_TAKERS["transform_column"]["calls"]\n )\n how_to_fix.append("Square brackets are for lists, dictionaries and table rows.")\n return "Square brackets do not work on a %s." % _pll_friendly(type_name), how_to_fix\n # Their own subscript, turned round: `s["year"]` becomes `s.year`.\n subscript = _pll_src_re.search(r"""([A-Za-z_][\\w.]*)\\s*\\[\\s*["\'](\\w+)["\']\\s*\\]""", e.text or "")\n holder, field = subscript.groups() if subscript else ("value", fields[0])\n return "Square brackets do not get a field out of `%s`." % name, [\n \'Use a dot instead: `%s.%s` rather than `%s["%s"]`.\' % (holder, field, holder, field),\n "Square brackets are for lists, dictionaries and table rows.",\n ]\n\n\n#: Built-ins that go through what they are given, item by item, and what\n#: each does with it - said when one is given something with no items.\n#: `map` and `filter` are given it second, after the function.\n_PLL_GOES_THROUGH = {\n "sum": "adds up the items in a list",\n "max": "needs a list, or two or more values",\n "min": "needs a list, or two or more values",\n "sorted": "sorts the items of a list or another collection",\n "list": "makes a list of the items of another collection",\n "tuple": "makes a tuple of the items of another collection",\n "set": "makes a set of the items of another collection",\n "frozenset": "makes a set of the items of another collection",\n "enumerate": "numbers the items of a list or another collection",\n "any": "checks the items of a list or another collection",\n "all": "checks the items of a list or another collection",\n "dict": "makes a dictionary from a collection of pairs",\n "map": "applies a function to the items of a collection",\n "filter": "keeps the items of a collection that pass a test",\n "zip": "pairs up the items of lists or other collections",\n}\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not iterable")\ndef _pll_not_iterable(m, e):\n """`\'int\' object is not iterable`: a `for` over something with no items,\n as `for x in len(xs)` is, or a built-in given one, as `sum(1, 3)` is.\n The two fail at the same place, so which it was is read from what the\n line was doing (the `instruction` fact); when that cannot say, only\n what is certain is said - never a `for` that is not there."""\n name = _pll_last_segment(m.group(1))\n instruction = str(e.facts.get("instruction") or "")\n if instruction == "GET_ITER":\n return _pll_loop_over_nothing(m, e, name)\n call = _pll_failed_call(e) if instruction.startswith("CALL") else None\n if call is not None and call[0] in _PLL_GOES_THROUGH:\n return _pll_given_no_items(call, e, m.group(1), name)\n if name == "NoneType":\n return "This value is `None`, so it has no items to go through.", _PLL_WHY_NONE\n return "%s has no items to go through." % _pll_capitalized(_pll_a(_pll_friendly(m.group(1)))), [\n "Use a list, a string, a range or something else with items in it."\n ]\n\n\ndef _pll_given_no_items(call, e, type_name, name):\n """`sum(1, 3)`: a built-in that goes through what it is given, given a\n value with no items - its first argument, or `map`\'s and `filter`\'s\n second. `zip`\'s could be any of them, so none is named."""\n function, arguments, plain = call\n does = "`%s` %s" % (function, _PLL_GOES_THROUGH[function])\n at = 1 if function in ("map", "filter") else 0\n given = arguments[at] if function != "zip" and at < len(arguments) else None\n if given is None:\n return "%s, and one of the values it was given has no items." % does, [\n "Give it lists, strings, ranges or other things with items in them."\n ]\n if name == "NoneType":\n subject, why = _pll_why_none(e, given)\n return "%s, but %s." % (does, subject), why\n headline = "%s, but `%s` is %s." % (does, given, _pll_a(_pll_friendly(type_name)))\n if function == "sum":\n if plain and len(arguments) == 2:\n return headline, [\n "Put the numbers in a list: `sum([%s])`." % ", ".join(arguments),\n "Or add them with `+`: `%s`." % " + ".join(arguments),\n ]\n return headline, ["Give `sum` a list of the numbers to add."]\n if function in ("max", "min"):\n return headline, ["Give it the list itself, or two or more values separated by commas."]\n return headline, ["Give it a list, a string, a range or something else with items in it."]\n\n\n@_pll_stock("TypeError", r"^sum\\(\\) takes at most 2 arguments")\ndef _pll_sum_values(m, e):\n """`sum(1, 2, 3)`: the numbers given one by one, where `sum` takes one\n list of them."""\n call = _pll_failed_call(e)\n numbers = call[1] if call is not None and call[0] == "sum" and call[2] else None\n headline = "`sum` adds up the items in one list, not values given one by one."\n if numbers is None:\n return headline, ["Put the numbers in a list, in square brackets, and give `sum` the list."]\n return headline, [\n "Put the numbers in a list: `sum([%s])`." % ", ".join(numbers),\n "Or add them with `+`: `%s`." % " + ".join(numbers),\n ]\n\n\ndef _pll_failed_call(e):\n """The call of a built-in that raised `e` itself, as `(name, arguments,\n plain)` - its positional arguments as written, `plain` when they are\n all there is, with no `*` or keyword - or None when that cannot be read\n off the program for certain: the student\'s code was not failing at a\n call (the `instruction` fact), the line holds no one such call at the\n error\'s column, or the program gives the name a value of its own."""\n if not str(e.facts.get("instruction") or "").startswith("CALL") or e.line is None:\n return None\n run = getattr(e, "run", None)\n if run and e.file == run[0]:\n source, line, column = run[1], e.line, e.column\n elif e.text:\n source = e.text.lstrip()\n line = 1\n column = None if e.column is None else e.column - (len(e.text) - len(source))\n else:\n return None\n try:\n tree = _ast.parse(source)\n except SyntaxError:\n return None\n own = set()\n for node in _ast.walk(tree):\n if isinstance(node, _ast.Name) and isinstance(node.ctx, (_ast.Store, _ast.Del)):\n own.add(node.id)\n elif isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n own.add(node.name)\n elif isinstance(node, (_ast.Import, _ast.ImportFrom)):\n own.update((alias.asname or alias.name).split(".")[0] for alias in node.names)\n calls = [\n node\n for node in _ast.walk(tree)\n if isinstance(node, _ast.Call)\n and isinstance(node.func, _ast.Name)\n and node.func.id not in own\n and node.lineno == line\n and (column is None or node.col_offset == column)\n ]\n if len(calls) != 1:\n return None\n call = calls[0]\n plain = not call.keywords and not any(isinstance(a, _ast.Starred) for a in call.args)\n return call.func.id, [_ast.get_source_segment(source, a) for a in call.args], plain\n\n\ndef _pll_a(words):\n """`words` after "a" or "an"."""\n first = words.lstrip("`")[:1].lower()\n return ("an " if first and first in "aeiou" else "a ") + words\n\n\ndef _pll_capitalized(text):\n return text[:1].upper() + text[1:]\n\n\ndef _pll_loop_over_nothing(m, e, name):\n """A `for` - or a comprehension\'s - over a value with no items."""\n over = _pll_src_re.search(r"\\bfor\\s+\\w+\\s+in\\s+len\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*\\)", e.text or "")\n if over:\n return "`len(%s)` is a number, and a number is not something to loop over." % over.group(1), [\n "To visit each item, write `for item in %s:`." % over.group(1),\n "To count, write `for i in range(len(%s)):` - but the first form is usually what you want." % over.group(1),\n ]\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_looped_over(e.text))\n return "%s, so there is nothing to loop over." % subject, why\n looped = _pll_looped_over(e.text)\n if name == "method" and looped is not None and _pll_src_re.search(r"\\.\\w+$", looped):\n # `for p in people.rows:` - the method itself, not the rows it gives.\n return "`%s` is the method itself, not what it gives back - it has not been called." % looped, [\n "Call it, with brackets: `for ... in %s():`." % looped\n ]\n return "A %s is not something to loop over." % _pll_friendly(m.group(1)), [\n "`for` needs a list, a string, a range or something else with items in it."\n ]\n\n\n@_pll_stock("TypeError", r\'can only concatenate (str|list|tuple) \\(not "([\\w.]+)"\\) to \\1\')\ndef _pll_concat_mismatch(m, e):\n """`can only concatenate str (not "int") to str`, and the same for lists\n and tuples. For a string the question is which side to convert; for a\n list it is almost always one item that belongs in brackets."""\n container, other = m.groups()\n headline = "A %s and a %s cannot be added together." % (_pll_friendly(container), _pll_friendly(other))\n line = e.text or ""\n if container == "str":\n # Built from the line when its two operands can be read: a string\n # literal on one side of the `+` and an expression on the other.\n pair = _pll_concat_operands(line)\n how_to_fix = []\n if pair is not None:\n text, value, text_first = pair\n how_to_fix.append(\n "To join them as text, convert `%s` with `str`: `%s`."\n % (value, "%s + str(%s)" % (text, value) if text_first else "str(%s) + %s" % (value, text))\n )\n if _pll_src_re.match(r"\\s*print\\s*\\(", line):\n spaced = _pll_without_joining_space(text, text_first)\n how_to_fix.append(\n "In `print`, a comma needs no conversion at all, and puts a space in for you: `print(%s)`."\n % ("%s, %s" % (spaced, value) if text_first else "%s, %s" % (value, spaced))\n )\n else:\n # `age + 1` where `age = input(...)`: the text is a number someone\n # typed, and adding to it is the point - so converting it comes\n # first, and joining as text is not the advice at all.\n typed = _pll_typed_operand(line, e.facts)\n if typed is not None:\n return headline, [\n "`%s` came from `input`, which always gives back text - even when a number is typed." % typed[0],\n "Convert it first: `%s`." % typed[1],\n ]\n how_to_fix += [\n "To join them as text, convert the other value with `str(...)`.",\n "In `print`, separate them with commas instead, which needs no conversion.",\n ]\n how_to_fix.append("To add them as numbers instead, convert the text with `int(...)` or `float(...)`.")\n return headline, how_to_fix\n # A list (or tuple) and one value: `xs + "!"`. What was meant is nearly\n # always that one value as a new item.\n operand = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\+", line)\n named = operand.group(1) if operand else "xs"\n opening, closing = ("[", "]") if container == "list" else ("(", ",)")\n how_to_fix = [\n "`+` joins a %s to another %s. To add one item, put it inside %s: `%s + %sitem%s`."\n % (container, container, "brackets" if container == "list" else "a tuple", named, opening, closing)\n ]\n if container == "list":\n how_to_fix.append(\n "Or add it in place with `%s.append(item)`, which changes `%s` and returns nothing." % (named, named)\n )\n return headline, how_to_fix\n\n\n@_pll_stock("TypeError", r"^unsupported operand type\\(s\\) for (\\S+?): \'([\\w.]+)\' and \'([\\w.]+)\'")\ndef _pll_operand_mismatch(m, e):\n """`unsupported operand type(s) for +: \'NoneType\' and \'int\'`"""\n operator, left, right = m.groups()\n if left != "NoneType" and right != "NoneType":\n return "`%s` does not work between a %s and a %s." % (operator, _pll_friendly(left), _pll_friendly(right)), (\n ["Convert one of them first - `int(...)` for a number, `str(...)` for text."]\n if (left == "str") != (right == "str")\n else []\n )\n # The usual cause: a function that changes something rather than\n # returning it, used as though it returned.\n side = "left" if left == "NoneType" else "right"\n subject, why = _pll_why_none(e, _pll_operand_beside(e.text, operator, side), "One of these values is `None`")\n return "%s, so `%s` cannot be used on it." % (subject, operator), why\n\n\n@_pll_stock("TypeError", r"^\'(\\S+)\' not supported between instances of \'([\\w.]+)\' and \'([\\w.]+)\'")\ndef _pll_comparison_mismatch(m, e):\n """`\'<\' not supported between instances of \'str\' and \'int\'`. The advice\n has to come from the types in hand: a fixed example such as\n `int("999")` would fit only some of the comparisons it is shown for."""\n operator, left, right = m.groups()\n text = left if left == "str" else right if right == "str" else None\n number = next(\n (_pll_last_segment(t) for t in (left, right) if _pll_last_segment(t) in ("int", "float")), None\n )\n if text is not None and number is not None:\n # The one case with an actual choice to offer: which side to convert.\n how_to_fix = [\n "Convert the text to a number with `%s(...)`, or the number to text with `str(...)` - whichever this"\n " comparison is meant to be about." % number\n ]\n else:\n how_to_fix = ["`%s` only orders values of the same kind; there is no answer to this comparison." % operator]\n return "`%s` cannot compare a %s with a %s." % (operator, _pll_friendly(left), _pll_friendly(right)), how_to_fix\n\n\n@_pll_stock("ValueError", r"^invalid literal for int\\(\\) with base 10: (.+)$")\ndef _pll_bad_int_literal(m, e):\n """`invalid literal for int() with base 10: \'nineteen\'`. Nothing typed\n and a word typed are different mistakes, and when the text came from\n `input` on this line, the line says so."""\n raw = m.group(1).strip()\n empty = raw in ("\'\'", \'""\')\n # Python prints the repr, `\'nineteen\'`; the course writes `"nineteen"`.\n shown = \'"%s"\' % raw[1:-1] if _pll_src_re.fullmatch(r"\'[^\'\\"]*\'", raw) else raw\n how_to_fix = [\'`int` only reads whole numbers written in digits, like `"19"`.\']\n if _pll_src_re.search(r"\\binput\\s*\\(", e.text or ""):\n how_to_fix.append(\n "Nothing was typed: pressing Enter on its own makes `input` give back an empty string."\n if empty\n else "`input` gives back exactly what was typed, as text, so a word cannot become a number."\n )\n if empty:\n return "`int` cannot turn an empty string into a whole number.", how_to_fix\n return "`int` cannot turn %s into a whole number." % shown, how_to_fix\n\n\n@_pll_stock("IndexError", r"index out of range")\ndef _pll_index_range(m, e):\n """`list index out of range`"""\n subscript = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\[\\s*([^\\]]+?)\\s*\\]", e.text or "")\n # Python read the real length from the frame; a made-up "list of 3"\n # read as a claim about theirs.\n sequence, length = e.facts.get("sequence"), e.facts.get("length")\n if sequence is not None and length is not None:\n return "`%s` has no item at that position." % sequence, [\n "`%s` is empty, so it has no items at all." % sequence\n if length == 0\n else "`%s` has %d item%s, numbered 0 to %d - positions start at 0."\n % (sequence, length, "" if length == 1 else "s", length - 1)\n ]\n named = "`%s`" % subscript.group(1) if subscript else "This list"\n return "%s has no item at that position." % named, [\n "Positions start at 0, so the last item of a list is at position `len(...) - 1`.",\n "`len(%s)` gives the number of items." % subscript.group(1) if subscript else "`len(...)` gives the number of items.",\n ]\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not callable")\ndef _pll_not_callable(m, e):\n """`\'int\' object is not callable`, usually a missing `*`."""\n if m.group(1) == "types.UnionType":\n # `types.UnionType` is an implementation name for `Boa | Armadillo`.\n called = _pll_call_on(e.text)\n members = _pll_union_members_of(e, called) if called is not None else None\n return (\n "`%s` is a union of several types, not something to make one of." % called\n if called is not None\n else "This is a union of several types, not something to make one of."\n ), (\n ["Make one of %s instead, like `%s(...)`." % (_pll_list_names(members), members[0])]\n if members is not None\n else ["Make one of the types the union is built from."]\n )\n # `3(width)` - a number followed by brackets is nearly always a missing\n # `*`. Python\'s own "perhaps you missed a comma?" points elsewhere.\n juxtaposed = _pll_src_re.search(r"(\\d+(?:\\.\\d+)?)\\s*\\(([^()]*)\\)", e.text or "")\n if juxtaposed and _pll_last_segment(m.group(1)) in ("int", "float"):\n written, number, inside = juxtaposed.group(0), juxtaposed.group(1), juxtaposed.group(2)\n return "Brackets after `%s` look like a function call, not multiplication." % number, [\n "To multiply, write the `*`: `%s * %s`, not `%s`." % (number, inside.strip() or "...", written)\n ]\n return "A %s cannot be called like a function." % _pll_friendly(m.group(1)), [\n "Brackets after a value mean a call; check whether a `*` or a comma is missing."\n ]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) accepts (\\d+) positional sub-patterns \\((\\d+) given\\)")\ndef _pll_pattern_arity(m, e):\n """`Boa() accepts 2 positional sub-patterns (3 given)`"""\n class_name, accepts, given = m.group(1), int(m.group(2)), m.group(3)\n fields = _pll_fields_of(e, class_name)\n return "`%s` has %s%s, but this pattern names %s." % (\n class_name, _pll_plural(accepts, "field"), " (%s)" % _pll_list_names(fields) if fields is not None else "", given\n ), (\n ["Write one name for each field: `case %s(%s):`." % (class_name, ", ".join(fields))]\n if fields is not None\n else ["Write one name for each field, in order."]\n )\n\n\n@_pll_stock("TypeError", r"list indices must be integers or slices, not tuple")\ndef _pll_missing_comma(m, e):\n """Two lists written next to each other with no comma between them -\n `[["Jan", 1] ["Feb", 2]]` - which Python reads as looking something up\n in the first one."""\n return "A comma is missing between two values in a list.", [\n "`[a, b] [c, d]` with no comma reads as looking `c, d` up inside the first list.",\n "Write `[[a, b], [c, d]]`, with a comma after every row.",\n ]\n\n\n@_pll_stock("RecursionError", r"maximum recursion depth")\ndef _pll_recursion_depth(m, e):\n """`maximum recursion depth exceeded`: the repeated frame is the\n function that never got smaller, repeated hundreds of times."""\n counts = {}\n for frame in e.frames:\n if frame["function"] is not None:\n counts[frame["function"]] = counts.get(frame["function"], 0) + 1\n best, best_count = None, 0\n for name, count in counts.items():\n if count > best_count:\n best, best_count = name, count\n named = "`%s`" % best if best_count >= 3 else "A function"\n return "%s kept calling itself and never stopped." % named, [\n "Each call has to work on something smaller - the rest of the list, a smaller number.",\n "Check that there is a case that returns without calling again, and that it is reached.",\n ]\n\n\n@_pll_stock("ValueError", r"All arrays must be of the same length")\ndef _pll_uneven_columns(m, e):\n """pandas\' `All arrays must be of the same length`, for a DataFrame\'s dict."""\n how_to_fix = [\n "Each list in the dictionary is one column, and every column needs one value for each row - so the lists"\n " have to be the same length."\n ]\n if _pll_src_re.search(r"DataFrame\\s*\\(", e.text or ""):\n how_to_fix.append("Count the values in each list on this line; one has more or fewer than the rest.")\n return "The columns of this DataFrame have different numbers of values.", how_to_fix\n\n\n@_pll_stock("KeyError", r"^\\s*\'[\\s\\S]*\'\\s*$")\ndef _pll_pandas_key_error(m, e):\n """pandas `KeyError: \'ratings\'`, raised deep inside pandas\' indexing."""\n # A frame inside the installed library, not merely the word "pandas":\n # `samples/pandas.py` is a real file, and a plain dict `KeyError` in it\n # is not a missing DataFrame column.\n inside = any(\n _pll_src_re.search(r"[/\\\\]pandas[/\\\\]core[/\\\\]|site-packages[/\\\\]pandas", frame["file"] or "")\n for frame in e.frames\n )\n if not inside:\n return None\n key = _pll_src_re.fullmatch(r"\'([\\s\\S]*)\'", e.message.strip())\n if key is None:\n return None\n frame = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\[", e.text or "")\n return "There is no column named `%s`." % key.group(1), [\n "`print(%s.columns)` lists the columns this DataFrame has." % frame.group(1)\n if frame\n else "`.columns` lists the columns a DataFrame has.",\n "Column names are case-sensitive, and spaces count.",\n ]\n';
224
+ var stock_default = '# PLL\'s wording for the errors Python words badly for a beginner.\n#\n# Each rule reads Python\'s message and, where it needs more, the student\'s\n# line and what Python found out about their code: Python says `pen_cost()\n# missing 1 required positional argument: \'message\'` without saying that\n# `pen_cost` takes two, and the definition Python found does.\n#\n# A rule returns None when on a closer look it cannot say anything better\n# than Python did, and the message is then shown as Python wrote it (the\n# catch-all, in finding.py). That is the right default - a confident wrong\n# explanation is worse than a terse right one - so rules match narrowly and\n# give up readily. Several do nothing but delete Python\'s internal names\n# (`__init__`, `types.UnionType`, `Table.`), which is worth a rule on its\n# own: those names send a student looking for something they never wrote.\n\n#: `(types, pattern, explain)` in the order they are tried.\n_PLL_STOCK_RULES = []\n\n\ndef _pll_stock(types, pattern):\n """Register a rewording of Python\'s message `pattern` for errors of\n `types`: a function of the match and the error, giving `(headline,\n how_to_fix)`, or None to leave the error to the rules after it."""\n\n def register(explain):\n _PLL_STOCK_RULES.append((types.split(), _pll_src_re.compile(pattern), explain))\n return explain\n\n return register\n\n\n@_pll_explains(\n "TypeError", "AttributeError", "ValueError", "IndexError", "RecursionError", "KeyError", "ModuleNotFoundError"\n)\ndef _pll_explain_stock(e):\n for types, pattern, explain in _PLL_STOCK_RULES:\n if e.type not in types:\n continue\n found = pattern.search(e.message)\n said = explain(found, e) if found else None\n if said is not None:\n return ("stock-message",) + said\n return None\n\n\n# -- shared wording -----------------------------------------------------------\n\n#: Why a value is `None`, which is almost never obvious to a beginner.\n_PLL_WHY_NONE = [\n "A function with no `return` gives back `None`.",\n "A method that changes something in place - `append`, `sort` - also gives back `None`.",\n]\n\n#: Everything a function can be handed to: a library function, or a handler keyword.\n_PLL_CALLS_YOUR_FUNCTION = tuple(_PLL_FUNCTION_TAKERS) + _PLL_HANDLER_KEYWORDS\n\n_PLL_COMPARISONS = ("<", ">", "<=", ">=", "==", "!=")\n\n\ndef _pll_last_segment(type_name):\n return type_name.split(".")[-1]\n\n\ndef _pll_friendly(type_name):\n """`str` -> "string", and any of the picture classes -> "image"\n (`_pll_course_type_name`). A class the student wrote keeps its own name."""\n name = _pll_course_type_name(_pll_last_segment(type_name))\n if name == "Image":\n return "image"\n words = _pll_type_words(name)\n return words if words is not None else "`%s`" % name\n\n\ndef _pll_a_friendly(type_name):\n """`_pll_friendly`\'s words for one value of the type: "a string", "an\n image", "an `Elephant`" - but "`None`", which takes no article."""\n return _pll_type_words(_pll_last_segment(type_name), article=True) or _pll_a(_pll_friendly(type_name))\n\n\ndef _pll_list_names(names):\n """`a`, `b` and `c` - for listing names in prose."""\n return _pll_listed(["`%s`" % name for name in names])\n\n\ndef _pll_listed(items, joined="and"):\n """a, b and c - or "or" - for listing anything in prose."""\n if len(items) <= 1:\n return "".join(items)\n return "%s %s %s" % (", ".join(items[:-1]), joined, items[-1])\n\n\ndef _pll_takes_clause(names, count):\n """"takes 2 arguments (`a`, `b`)", or "takes 2 arguments" if unknown."""\n if names:\n return "takes %s (%s)" % (_pll_plural(len(names), "argument"), _pll_list_names(names))\n return "takes %s" % _pll_plural(count, "argument")\n\n\ndef _pll_needs_more(name, missing, message):\n absent = _pll_src_re.findall(r"\'(\\w+)\'", message)\n return "`%s` needs %s%s." % (name, _pll_plural(missing, "more argument"), ": " + _pll_list_names(absent) if absent else "")\n\n\ndef _pll_library_hint(name):\n return list(_PLL_LIBRARY_HINTS.get(name, []))\n\n\n# -- what Python found the names to be ----------------------------------------\n\n\ndef _pll_defined_as(e, name):\n return (e.facts.get("definitions") or {}).get(name)\n\n\ndef _pll_parameters_of(e, name, which):\n """A function\'s parameters - those it requires, or every positional one."""\n found = _pll_defined_as(e, name)\n return found[which] if found and found["kind"] == "function" else None\n\n\ndef _pll_students_class(e, name):\n found = _pll_defined_as(e, name)\n return bool(found and found["kind"] == "class" and found["students"])\n\n\ndef _pll_fields_of(e, name):\n """The fields of one of the student\'s classes, or None when it has none."""\n found = _pll_defined_as(e, name)\n return found["fields"] if found and found["kind"] == "class" and found["fields"] else None\n\n\ndef _pll_union_members_of(e, name):\n found = _pll_defined_as(e, name)\n return found["members"] if found and found["kind"] == "union" and len(found["members"]) > 1 else None\n\n\n# -- reading the student\'s line -----------------------------------------------\n#\n# Several rules want "the expression next to this `+`" or "the argument of\n# this `filter(`", written exactly as the student wrote it. A regex could\n# not see past a nested call - `"Total: " + add_shipping(pen_cost(10,\n# "bravo"))` was answered with `str(add_shipping)`, converting the function\n# rather than its result. Tokens let brackets balance and strings be\n# skipped, which is all these rules need.\n\n#: The calls on a line that are almost never what produced the offending\n#: value, so they are only named when nothing else is there.\n_PLL_WRAPPERS = frozenset(\n ("print", "str", "int", "float", "bool", "len", "list", "sorted", "round", "abs", "sum", "min", "max", "type", "repr")\n)\n\n\nclass _PllToken:\n __slots__ = ("kind", "text", "start", "end")\n\n def __init__(self, kind, text, start, end):\n self.kind, self.text, self.start, self.end = kind, text, start, end\n\n\ndef _pll_line_tokens(line):\n """One line of Python, split into enough tokens to find an operand."""\n tokens = []\n i, n = 0, len(line)\n while i < n:\n c = line[i]\n if c == "#":\n break\n if c.isspace():\n i += 1\n continue\n start = i\n if c in "\\"\'":\n i += 1\n while i < n and line[i] != c:\n i += 2 if line[i] == "\\\\" else 1\n i = min(i + 1, n)\n tokens.append(_PllToken("str", line[start:i], start, i))\n elif c.isascii() and (c.isalpha() or c == "_"):\n while i < n and line[i].isascii() and (line[i].isalnum() or line[i] == "_"):\n i += 1\n tokens.append(_PllToken("name", line[start:i], start, i))\n elif c in "0123456789":\n while i < n and line[i] in "0123456789.":\n i += 1\n tokens.append(_PllToken("num", line[start:i], start, i))\n elif c in "([{":\n i += 1\n tokens.append(_PllToken("open", c, start, i))\n elif c in ")]}":\n i += 1\n tokens.append(_PllToken("close", c, start, i))\n else:\n two = line[i : i + 2]\n op = two if two in ("<=", ">=", "==", "!=", "**", "//") else c\n i += len(op)\n tokens.append(_PllToken("op", op, start, i))\n return tokens\n\n\ndef _pll_token(tokens, at):\n """The token at `at`, or None past either end."""\n return tokens[at] if 0 <= at < len(tokens) else None\n\n\ndef _pll_text_at(tokens, at):\n token = _pll_token(tokens, at)\n return token.text if token else None\n\n\ndef _pll_closing(tokens, open_at):\n """The index of the bracket closing the one opened at `open_at`, or -1."""\n depth = 0\n for i in range(open_at, len(tokens)):\n if tokens[i].kind == "open":\n depth += 1\n if tokens[i].kind == "close":\n depth -= 1\n if depth == 0:\n return i\n return -1\n\n\ndef _pll_operand_from(line, tokens, at):\n """The expression starting at token `at`: a name, its attributes and calls."""\n if at >= len(tokens) or tokens[at].kind not in ("name", "num"):\n return None\n last, i = at, at + 1\n while i < len(tokens):\n following = _pll_token(tokens, i + 1)\n if tokens[i].text == "." and following is not None and following.kind == "name":\n last = i + 1\n i += 2\n elif tokens[i].kind == "open" and tokens[i].text != "{":\n shut = _pll_closing(tokens, i)\n if shut < 0:\n return None\n last = shut\n i = shut + 1\n else:\n break\n return line[tokens[at].start : tokens[last].end]\n\n\ndef _pll_operand_before(line, tokens, at):\n """The expression ending at token `at`, read backwards to where it starts."""\n if not 0 <= at < len(tokens):\n return None\n first = i = at\n while i >= 0:\n if tokens[i].kind == "close":\n # Walk back to the matching open bracket.\n depth, j = 0, i\n while j >= 0:\n if tokens[j].kind == "close":\n depth += 1\n if tokens[j].kind == "open":\n depth -= 1\n if depth == 0:\n break\n j -= 1\n if j < 0:\n return None\n first = j\n i = j - 1\n continue\n if tokens[i].kind in ("name", "num"):\n first = i\n before = _pll_token(tokens, i - 2)\n if _pll_text_at(tokens, i - 1) == "." and before is not None and before.kind == "name":\n i -= 2\n continue\n break\n if i == at:\n return None\n break\n if tokens[first].kind not in ("name", "num"):\n return None\n return line[tokens[first].start : tokens[at].end]\n\n\ndef _pll_concat_operands(line):\n """A string literal and the operand `+` joins it to, as written:\n `(text, value, text_first)`, or None."""\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n if token.kind != "str":\n continue\n if _pll_text_at(tokens, i + 1) == "+":\n value = _pll_operand_from(line, tokens, i + 2)\n if value is not None:\n return token.text, value, True\n if _pll_text_at(tokens, i - 1) == "+" and i >= 2:\n value = _pll_operand_before(line, tokens, i - 2)\n if value is not None:\n return token.text, value, False\n return None\n\n\ndef _pll_attribute_receiver(line, attr):\n """The expression `.attr` is read from on `line`, as written."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i in range(2, len(tokens)):\n if tokens[i].text == attr and tokens[i - 1].text == ".":\n return _pll_operand_before(line, tokens, i - 2)\n return None\n\n\ndef _pll_subscript_receiver(line):\n """The expression subscripted on `line` - the `xs` of `xs[0]`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i in range(1, len(tokens)):\n before = tokens[i - 1]\n if tokens[i].text == "[" and (before.kind == "name" or before.text in (")", "]")):\n return _pll_operand_before(line, tokens, i - 1)\n return None\n\n\ndef _pll_looped_over(line):\n """What `for ... in` loops over on `line`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n if not tokens or tokens[0].text != "for":\n return None\n at = next((i for i, token in enumerate(tokens) if token.text == "in"), -1)\n return None if at < 0 else _pll_operand_from(line, tokens, at + 1)\n\n\ndef _pll_operand_beside(line, operator, side):\n """The operand on one side of the first `operator` on `line`."""\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n at = next((i for i, token in enumerate(tokens) if token.kind == "op" and token.text == operator), -1)\n if at < 0:\n return None\n return _pll_operand_before(line, tokens, at - 1) if side == "left" else _pll_operand_from(line, tokens, at + 1)\n\n\ndef _pll_call_on(line):\n """The call on `line` whose result the error is probably about: not a\n wrapper like `print`, unless it is the only call there."""\n names = _pll_src_re.findall(r"([A-Za-z_][\\w.]*)\\s*\\(", line or "")\n if not names:\n return None\n return next((name for name in names if name not in _PLL_WRAPPERS), names[0])\n\n\ndef _pll_typed_operand(line, facts):\n """A `+` operand that holds what `input()` returned, and the sum with it\n converted: `age + 1` becomes `int(age) + 1`."""\n tokens = _pll_line_tokens(line)\n assigned = facts.get("assigned") or {}\n for i, token in enumerate(tokens):\n if token.text != "+":\n continue\n for at, side in ((i - 1, "left"), (i + 1, "right")):\n operand = _pll_token(tokens, at)\n if (\n operand is None\n or operand.kind != "name"\n or _pll_text_at(tokens, at + 1) == "("\n or _pll_text_at(tokens, at - 1) == "."\n ):\n continue\n if (assigned.get(operand.text) or {}).get("call") != "input":\n continue\n left = "int(%s)" % operand.text if side == "left" else _pll_operand_before(line, tokens, i - 1)\n right = "int(%s)" % operand.text if side == "right" else _pll_operand_from(line, tokens, i + 1)\n if left is None or right is None:\n continue\n return operand.text, "%s + %s" % (left, right)\n return None\n\n\ndef _pll_why_none(e, receiver, unknown="This value is `None`"):\n """Where the `None` came from, said about the student\'s own code, as\n `(subject, how_to_fix)`.\n\n `receiver` is what the line did something to - the `x` of `x.total`,\n the thing subscripted, looped over or added. If it is a call, that call\n gave back `None`. If it is a name, the call it was set from did, and\n saying which is the useful part: blaming the first call on the line\n named `print` in `print(x.total)`, where `print` only wraps the value.\n """\n call = _pll_src_re.match(r"([A-Za-z_][\\w.]*)\\s*\\(", receiver) if receiver is not None else None\n if call:\n return "`%s(...)` gave back `None`" % call.group(1), _PLL_WHY_NONE\n if receiver is not None and _pll_src_re.fullmatch(r"[A-Za-z_][\\w.]*", receiver):\n subject = "`%s` is `None`" % receiver\n # A parameter got its `None` from whoever called the function.\n frames = e.user_frames\n inside = frames[-1]["function"] if frames else None\n if inside is not None and receiver in (frames[-1]["parameters"] or []):\n caller = " on line %s" % frames[-2]["line"] if len(frames) >= 2 else ""\n return subject, [\n "`%s` is a parameter of `%s`, so the call%s gave it `None`." % (receiver, inside, caller)\n ] + _PLL_WHY_NONE\n set_from = (e.facts.get("assigned") or {}).get(receiver)\n if set_from is None:\n return subject, _PLL_WHY_NONE\n return subject, [\n "`%s` was set from `%s(...)` on line %s, which gave back `None`." % (receiver, set_from["call"], set_from["line"])\n ] + _PLL_WHY_NONE\n # Nothing on the line to point at: name a call, but never a wrapper\n # like `print`, which is not where the value came from.\n named = next(\n (name for name in _pll_src_re.findall(r"([A-Za-z_][\\w.]*)\\s*\\(", e.text or "") if name not in _PLL_WRAPPERS),\n None,\n )\n return ("`%s(...)` gave back `None`" % named if named is not None else unknown), _PLL_WHY_NONE\n\n\ndef _pll_without_joining_space(literal, before):\n """The literal with the space `print` will add for it taken out:\n `print("Next year you will be ", age)` prints two spaces."""\n quote, inner = literal[0], literal[1:-1]\n trimmed = _pll_src_re.sub(r" $", "", inner, count=1) if before else _pll_src_re.sub(r"^ ", "", inner, count=1)\n return quote + trimmed + quote\n\n\n# -- the rules, in the order they are tried -----------------------------------\n\n\n@_pll_stock("ModuleNotFoundError", r"^No module named")\ndef _pll_missing_module(m, e):\n """`No module named \'flask\'`, said as why: PLL\'s Python is Pyodide\'s,\n whose packages are downloaded before a program runs, from the imports\n PLL can read in it."""\n module = e.facts.get("module")\n if module is None:\n return None\n name, kind = module["name"], module["kind"]\n if kind == "leftOut":\n return "`%s.py` is next to your program, but it was not loaded: %s." % (name, module.get("why")), [\n "A file that was not loaded cannot be imported."\n ]\n if kind == "notLoaded":\n return "`%s` was not loaded before your program ran." % name, [\n "PLL downloads `%s` the first time a program imports it, so this needs the internet once; after that it"\n " is kept. Check the connection, and run the program again." % module.get("package"),\n "PLL loads a package from the `import` lines it reads before the program runs: if this import is made"\n " another way, with a name worked out as it runs, add `import %s` near the top of the file." % name,\n ]\n if kind == "missing":\n return "There is no module called `%s` here." % name, [\n "PLL runs Python with Pyodide, which has many packages - numpy, pandas, matplotlib, requests and more -"\n " but not `%s`." % name,\n "If `%s` is a file of your own, put `%s.py` next to your program." % (name, name),\n ]\n return None\n\n\n@_pll_stock("TypeError", r"Table|^a table ")\ndef _pll_builtin_on_table(m, e):\n """`sum(people, "age")`: Python\'s own `sum`, `min` or `max` given a table\n and a column. A table does that itself."""\n call = _pll_src_re.search(\n r"""\\b(sum|min|max|len|sorted)\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*,\\s*("[^"]*"|\'[^\']*\')\\s*\\)""", e.text or ""\n )\n if call is None:\n return None\n fn, table, column = call.groups()\n method = "length" if fn == "len" else "order_by" if fn == "sorted" else fn\n return "`%s(%s, %s)` is Python\'s own `%s`, which knows nothing of tables." % (fn, table, column, fn), [\n "A table does this itself: `%s.%s(%s)`." % (table, method, "" if method == "length" else column)\n ]\n\n\n@_pll_stock("TypeError", r"not supported between instances of|unsupported operand type")\ndef _pll_condition_not_function(m, e):\n """`filter("riders" < 1000)` - a condition where a function belongs.\n\n The comparison is worked out before `filter` is reached, so what Python\n reports is the comparison - accurately, and beside the point. `filter`\n calls a function on each row, and the condition has to be its body.\n """\n line = e.text\n if line is None:\n return None\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n taker = _PLL_FUNCTION_TAKERS.get(token.text)\n if taker is None or "each" not in taker or token.kind != "name" or _pll_text_at(tokens, i + 1) != "(":\n continue\n shut = _pll_closing(tokens, i + 1)\n if shut < 0:\n continue\n inside = tokens[i + 2 : shut]\n # A comparison at the argument\'s own level, and no `lambda` - which\n # would make it a function after all, anywhere in the argument.\n depth, compares, is_function = 0, False, False\n for part in inside:\n if part.kind == "open":\n depth += 1\n if part.kind == "close":\n depth -= 1\n if depth == 0 and part.text in _PLL_COMPARISONS:\n compares = True\n if part.text == "lambda":\n is_function = True\n if not compares or is_function:\n continue\n name = token.text\n receiver = _pll_text_at(tokens, i - 2) if _pll_text_at(tokens, i - 1) == "." else None\n # A column name compared directly - `"riders" < 1000` - is the text,\n # not the column; inside the function it is `r["riders"]`.\n condition, named = "", None\n cursor = tokens[i + 2].start if i + 2 < len(tokens) else 0\n for k, part in enumerate(inside):\n next_op = inside[k + 1].text if k + 1 < len(inside) else None\n prev_op = inside[k - 1].text if k >= 1 else None\n condition += line[cursor : part.start]\n if part.kind == "str" and name != "transform_column" and (\n next_op in _PLL_COMPARISONS or prev_op in _PLL_COMPARISONS\n ):\n condition += "r[%s]" % part.text\n named = named if named is not None else part.text\n else:\n condition += part.text\n cursor = part.end\n condition = condition.strip()\n call = "%s.%s(keep)" % (receiver, name) if receiver is not None else "%s(keep)" % name\n how_to_fix = [\n "`%s` calls a function on %s and %s, so the condition belongs inside a function. Here it was worked out"\n " first - which is what the error is about." % (name, taker["each"], taker["keeps"]),\n "Write `def keep(%s) -> bool:` with `return %s` as its body, then `%s`." % (taker["param"], condition, call),\n ]\n if named is not None:\n how_to_fix.append("On its own, `%s` is just text. The value in that column of a row is `r[%s]`." % (named, named))\n return "`%s` needs a function, but this gives it a condition." % name, how_to_fix\n return None\n\n\n@_pll_stock("ValueError", r"^reactor\'s `(\\w+)` has to be a function")\ndef _pll_handler_called(m, e):\n """A reactor\'s handler called rather than named: `to_draw=draw_dog(0)`\n has already run `draw_dog` and handed over its picture, so Python\n cannot say which function it was. The student\'s line still says."""\n line = e.text\n if line is None:\n return None\n handler = m.group(1)\n tokens = _pll_line_tokens(line)\n for i, token in enumerate(tokens):\n if token.text != handler or _pll_text_at(tokens, i + 1) != "=":\n continue\n called = _pll_operand_from(line, tokens, i + 2)\n after = _pll_token(tokens, i + 2)\n fn = after.text if after is not None and after.kind == "name" else None\n if called is None or fn is None or called == fn:\n continue\n return "`%s` has to be given the function itself, not what calling it gives back." % handler, [\n "Write `%s=%s`, not `%s=%s`." % (handler, fn, handler, called),\n "The reactor calls `%s` itself, with its state, every time it needs a picture." % fn,\n ]\n return None\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) missing \\d+ required positional argument")\ndef _pll_function_called_not_passed(m, e):\n """`filter(below_1k())` - the function called instead of passed.\n\n The call happens before `filter` is entered, so what Python reports is\n `below_1k() missing 1 required positional argument: \'r\'`, pointing at\n the `def` and mentioning neither `filter` nor the parentheses. Only the\n student\'s line says which it was.\n """\n line = e.text\n if line is None:\n return None\n name = m.group(1)\n # The empty call has to be *given to* the taker, not merely share a line\n # with it: `filter(below_1k())` or `to_draw=draw()`.\n taker = next(\n (\n candidate\n for candidate in _PLL_CALLS_YOUR_FUNCTION\n if _pll_src_re.search(r"\\b%s\\b\\s*(?:\\(|=)\\s*(?:[^()]*,\\s*)?%s\\s*\\(\\s*\\)" % (candidate, name), line)\n ),\n None,\n )\n if taker is None:\n return None\n written = "%s=%s" % (taker, name) if taker in _PLL_HANDLER_KEYWORDS else "%s(%s)" % (taker, name)\n return "`%s` calls `%s` for you, so it needs the function itself." % (taker, name), [\n "Leave the brackets off: `%s`." % written,\n "`%s()` calls it right here, with nothing to work on." % name,\n ]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) takes no arguments")\ndef _pll_missing_dataclass(m, e):\n """`ITunesSong() takes no arguments`: a class with annotated fields and\n no `@dataclass`. Exactly true and completely unhelpful: the student\n wrote three fields and is told the class takes nothing."""\n class_name = m.group(1)\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return None\n return "`%s` lists fields (%s) but has no `@dataclass`, so it takes no arguments." % (\n class_name, _pll_list_names(fields)\n ), [\n "Write `@dataclass` on the line above `class %s`." % class_name,\n "Add `from dataclasses import dataclass` at the top of the file if it is not there.",\n ]\n\n\n@_pll_stock(\n "TypeError",\n r"^(\\w+)\\.__init__\\(\\) (?:missing (\\d+) required positional argument|takes \\d+ positional arguments? but \\d+ (?:was|were) given)",\n)\ndef _pll_dataclass_arity(m, e):\n """A dataclass constructed with the wrong number of values. `__init__`\n is the thing to get rid of: the student wrote `ITunesSong(...)` and\n never wrote an `__init__`."""\n class_name = m.group(1)\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return "`%s` was given the wrong number of values." % class_name, [\n "Give one value for each field of `class %s`, in order." % class_name\n ]\n # Python counts `self`, so "takes 3 but 4 were given" is about 2 fields\n # and 3 values. Both counts are recomputed from the fields.\n given = _pll_src_re.search(r"takes \\d+ positional arguments? but (\\d+)", e.message)\n got = int(given.group(1)) - 1 if given else max(len(fields) - int(m.group(2)), 0)\n return "`%s` needs %s (%s), but got %d." % (\n class_name, _pll_plural(len(fields), "value"), _pll_list_names(fields), got\n ), ["Give one value for each field, in the order they are written in `class %s`." % class_name]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) missing (\\d+) required positional argument")\ndef _pll_too_few_arguments(m, e):\n """`f() missing 2 required positional arguments: \'a\' and \'b\'`"""\n name, missing = m.group(1), int(m.group(2))\n # A test function takes no parameters, so pytest has nothing to pass and\n # the test never runs.\n if name.startswith("test_"):\n # Python names the ones missing - all of them, called with nothing.\n named = _pll_src_re.findall(r"\'([^\']+)\'", e.message.split(":", 1)[1]) if ":" in e.message else []\n return "`%s` is a test, so it cannot take any parameters." % name, [\n _pll_test_fix(name, named or _pll_parameters_of(e, name, "required") or [], False, ())\n ]\n check = _pll_arity_check(e, name, "Check the call on this line - one value is needed for each parameter.")\n # Wherever it was defined: the student\'s file, another of theirs, or\n # PLL\'s own library.\n names = _pll_parameters_of(e, name, "required")\n if names is None:\n # An imported function from somewhere else: say only what is\n # missing, which Python did name, rather than inventing a total.\n return _pll_needs_more(name, missing, e.message), [check]\n return "`%s` %s, but got %d." % (name, _pll_takes_clause(names, len(names)), max(len(names) - missing, 0)), [\n check\n ] + _pll_library_hint(name)\n\n\ndef _pll_malformed_test_finding(name, parameters, filename, line, unused=()):\n """A `test_*` function with parameters, which the tests do not run: a\n test is called with nothing, so there would be no values for them. Said\n apart from the tests that ran - it is a mistake in how the test is\n written, not a test that failed. `name` is as the report lists it, a\n method\'s as `TestThings::test_one`; `unused` are the parameters its body\n never reads."""\n function = name.split("::")[-1]\n takes = (\n "a parameter, %s," % _pll_list_names(parameters)\n if len(parameters) == 1\n else "parameters - %s -" % _pll_list_names(parameters)\n )\n return {\n "id": "malformed-test",\n "errorType": "MalformedTest",\n "headline": "`%s` takes %s so it cannot be run as a test." % (function, takes),\n "howToFix": [\n _pll_test_fix(function, parameters, function != name, unused),\n "If it is not a test but a helper your tests use, give it a name that does not start with `test_`.",\n ],\n "fileName": filename,\n "lineNumber": line,\n "column": None,\n "nameToken": function,\n "severity": "error",\n "origin": "runtime",\n }\n\n\ndef _pll_test_fix(function, parameters, method, unused):\n """How a test written with `parameters` is written instead: with empty\n brackets - `self` only, for a method - and examples in its asserts where\n it used the parameters; or, when it used none of them, only the\n brackets. Unknown `parameters` are said as "its parameters"."""\n written = "def %s(self):" % function if method else "def %s():" % function\n if parameters and all(p in unused for p in parameters):\n return "Take %s out of the brackets - `%s` - since the test does not use %s." % (\n _pll_list_names(parameters), written, "it" if len(parameters) == 1 else "them"\n )\n used = [p for p in parameters if p not in unused]\n return "Write `%s`, with %s in the brackets, and use example values in its `assert`s instead of %s." % (\n written, "only `self`" if method else "nothing", _pll_list_names(used) if used else "its parameters"\n )\n\n\ndef _pll_arity_check(e, name, otherwise):\n """What to look at for a wrong number of values: the call on this line,\n or - when a table method made the call - the function\'s parameters."""\n taker = _PLL_FUNCTION_TAKERS.get(_pll_library_caller(e.frames))\n if taker is None or "param" not in taker:\n return otherwise\n return "%s, so give it one parameter: `def %s(%s):`." % (taker["calls"], name, taker["param"])\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) takes (\\d+) positional arguments? but (\\d+) (?:was|were) given")\ndef _pll_too_many_arguments(m, e):\n """`f() takes 1 positional argument but 2 were given`"""\n name, takes, given = m.group(1), int(m.group(2)), int(m.group(3))\n # Too many, so the limit is every parameter there is, optional included.\n names = _pll_parameters_of(e, name, "parameters")\n extra = given - (len(names) if names is not None else takes)\n return "`%s` %s, but got %d." % (name, _pll_takes_clause(names, takes), given), [\n _pll_arity_check(\n e,\n name,\n "Check the call on this line - there are %d values too many." % extra\n if extra > 1\n else "Check the call on this line - there is one value too many.",\n )\n ] + _pll_library_hint(name)\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\.(\\w+)\\(\\) missing (\\d+) required positional argument")\ndef _pll_method_arity(m, e):\n """A method called with too few arguments. Python names the class\n (`Table.scatter_plot()`), which the student may never have typed."""\n owner, method, missing = m.group(1), m.group(2), int(m.group(3))\n if method == "__init__":\n return None\n required = _pll_parameters_of(e, "%s.%s" % (owner, method), "required")\n if required is None:\n return _pll_needs_more(method, missing, e.message), []\n return "`%s` %s, but got %d." % (\n method, _pll_takes_clause(required, len(required)), max(len(required) - missing, 0)\n ), []\n\n\n@_pll_stock("AttributeError", r"^type object \'(\\w+)\' has no attribute \'(\\w+)\'")\ndef _pll_attribute_on_class(m, e):\n """The class used where one of its values was meant - `title(ITunesSong)`\n rather than `title(song)`. "type object" means nothing to a student."""\n class_name, asked = m.groups()\n fields = _pll_fields_of(e, class_name)\n if fields is None:\n return None\n return "`%s` on its own is the class, not one made from it, so it has no `%s`." % (class_name, asked), [\n "`%s(...)` makes one, with a value for each field (%s)." % (class_name, _pll_list_names(fields)),\n "A field belongs to the value, not to the class it was made from.",\n ]\n\n\n@_pll_stock("AttributeError", r"^\'([\\w.]+)\' object has no attribute \'(\\w+)\'")\ndef _pll_no_such_attribute(m, e):\n """`\'ITunesSong\' object has no attribute \'yaer\'`"""\n type_name, asked = m.groups()\n name = _pll_last_segment(type_name)\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_attribute_receiver(e.text, asked))\n return "%s, so it has no `%s`." % (subject, asked), why\n fields = _pll_fields_of(e, name)\n if fields is None:\n # A class of theirs with no fields - the empty end of a recursive\n # type, like `NoInfo` - has nothing to get, so the value needs\n # checking for before its fields are used.\n if _pll_students_class(e, name):\n return "`%s` has no fields at all, so it has no `%s`." % (name, asked), [\n "A value that might be %s needs a `case %s():` before `.%s` is used." % (_pll_a("`%s`" % name), name, asked)\n ]\n return "%s has no `%s`." % (_pll_capitalized(_pll_a_friendly(type_name)), asked), []\n suggestion = _pll_closest_name(asked, fields)\n return "`%s` has no field `%s` (its fields are %s)." % (name, asked, _pll_list_names(fields)), (\n ["Did you mean `%s`?" % suggestion] if suggestion is not None else []\n )\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not subscriptable")\ndef _pll_not_subscriptable(m, e):\n """`\'ITunesSong\' object is not subscriptable`"""\n type_name = m.group(1)\n name = _pll_last_segment(type_name)\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_subscript_receiver(e.text))\n return "%s, so square brackets cannot get anything out of it." % subject, why\n method = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\.(\\w+)\\s*\\[\\s*([^\\]]*)\\]", e.text or "")\n if name == "method" and method is not None:\n # `people.rows[0]`, `people.row[0]`: a method used as if it were a list.\n holder, called, inside = method.groups()\n return "`%s.%s` is a method, so it is called with round brackets, not indexed with square ones." % (\n holder, called\n ), [\n "Write `%s.row(%s)`: the row number goes in the round brackets." % (holder, inside)\n if called == "row"\n else "Call it first: `%s.%s()[%s]`." % (holder, called, inside)\n ]\n fields = _pll_fields_of(e, name)\n if fields is None:\n # `r["age"]` in a function `transform_column` calls: it is given one\n # value of the column, not the row.\n how_to_fix = []\n if _pll_library_caller(e.frames) == "transform_column":\n how_to_fix.append(\n "%s, so what your function is given is already the value - there is nothing to take out of it."\n % _PLL_FUNCTION_TAKERS["transform_column"]["calls"]\n )\n how_to_fix.append("Square brackets are for lists, dictionaries and table rows.")\n return "Square brackets do not work on %s." % _pll_a_friendly(type_name), how_to_fix\n # Their own subscript, turned round: `s["year"]` becomes `s.year`.\n subscript = _pll_src_re.search(r"""([A-Za-z_][\\w.]*)\\s*\\[\\s*["\'](\\w+)["\']\\s*\\]""", e.text or "")\n holder, field = subscript.groups() if subscript else ("value", fields[0])\n return "Square brackets do not get a field out of `%s`." % name, [\n \'Use a dot instead: `%s.%s` rather than `%s["%s"]`.\' % (holder, field, holder, field),\n "Square brackets are for lists, dictionaries and table rows.",\n ]\n\n\n#: Built-ins that go through what they are given, item by item, and what\n#: each does with it - said when one is given something with no items.\n#: `map` and `filter` are given it second, after the function.\n_PLL_GOES_THROUGH = {\n "sum": "adds up the items in a list",\n "max": "needs a list, or two or more values",\n "min": "needs a list, or two or more values",\n "sorted": "sorts the items of a list or another collection",\n "list": "makes a list of the items of another collection",\n "tuple": "makes a tuple of the items of another collection",\n "set": "makes a set of the items of another collection",\n "frozenset": "makes a set of the items of another collection",\n "enumerate": "numbers the items of a list or another collection",\n "any": "checks the items of a list or another collection",\n "all": "checks the items of a list or another collection",\n "dict": "makes a dictionary from a collection of pairs",\n "map": "applies a function to the items of a collection",\n "filter": "keeps the items of a collection that pass a test",\n "zip": "pairs up the items of lists or other collections",\n}\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not iterable")\ndef _pll_not_iterable(m, e):\n """`\'int\' object is not iterable`: a `for` over something with no items,\n as `for x in len(xs)` is, or a built-in given one, as `sum(1, 3)` is.\n The two fail at the same place, so which it was is read from what the\n line was doing (the `instruction` fact); when that cannot say, only\n what is certain is said - never a `for` that is not there."""\n name = _pll_last_segment(m.group(1))\n instruction = str(e.facts.get("instruction") or "")\n if instruction == "GET_ITER":\n return _pll_loop_over_nothing(m, e, name)\n call = _pll_failed_call(e) if instruction.startswith("CALL") else None\n if call is not None and call[0] in _PLL_GOES_THROUGH:\n return _pll_given_no_items(call, e, m.group(1), name)\n if name == "NoneType":\n return "This value is `None`, so it has no items to go through.", _PLL_WHY_NONE\n return "%s has no items to go through." % _pll_capitalized(_pll_a_friendly(m.group(1))), [\n "Use a list, a string, a range or something else with items in it."\n ]\n\n\ndef _pll_given_no_items(call, e, type_name, name):\n """`sum(1, 3)`: a built-in that goes through what it is given, given a\n value with no items - its first argument, or `map`\'s and `filter`\'s\n second. `zip`\'s could be any of them, so none is named."""\n function, arguments, plain = call\n does = "`%s` %s" % (function, _PLL_GOES_THROUGH[function])\n at = 1 if function in ("map", "filter") else 0\n given = arguments[at] if function != "zip" and at < len(arguments) else None\n if given is None:\n return "%s, and one of the values it was given has no items." % does, [\n "Give it lists, strings, ranges or other things with items in them."\n ]\n if name == "NoneType":\n subject, why = _pll_why_none(e, given)\n return "%s, but %s." % (does, subject), why\n headline = "%s, but `%s` is %s." % (does, given, _pll_a_friendly(type_name))\n if function == "sum":\n if plain and len(arguments) == 2:\n return headline, [\n "Put the numbers in a list: `sum([%s])`." % ", ".join(arguments),\n "Or add them with `+`: `%s`." % " + ".join(arguments),\n ]\n return headline, ["Give `sum` a list of the numbers to add."]\n if function in ("max", "min"):\n return headline, ["Give it the list itself, or two or more values separated by commas."]\n return headline, ["Give it a list, a string, a range or something else with items in it."]\n\n\n@_pll_stock("TypeError", r"^sum\\(\\) takes at most 2 arguments")\ndef _pll_sum_values(m, e):\n """`sum(1, 2, 3)`: the numbers given one by one, where `sum` takes one\n list of them."""\n call = _pll_failed_call(e)\n numbers = call[1] if call is not None and call[0] == "sum" and call[2] else None\n headline = "`sum` adds up the items in one list, not values given one by one."\n if numbers is None:\n return headline, ["Put the numbers in a list, in square brackets, and give `sum` the list."]\n return headline, [\n "Put the numbers in a list: `sum([%s])`." % ", ".join(numbers),\n "Or add them with `+`: `%s`." % " + ".join(numbers),\n ]\n\n\ndef _pll_failed_call(e):\n """The call of a built-in that raised `e` itself, as `(name, arguments,\n plain)` - its positional arguments as written, `plain` when they are\n all there is, with no `*` or keyword - or None when that cannot be read\n off the program for certain: the student\'s code was not failing at a\n call (the `instruction` fact), the line holds no one such call at the\n error\'s column, or the program gives the name a value of its own."""\n if not str(e.facts.get("instruction") or "").startswith("CALL") or e.line is None:\n return None\n run = getattr(e, "run", None)\n if run and e.file == run[0]:\n source, line, column = run[1], e.line, e.column\n elif e.text:\n source = e.text.lstrip()\n line = 1\n column = None if e.column is None else e.column - (len(e.text) - len(source))\n else:\n return None\n try:\n tree = _ast.parse(source)\n except SyntaxError:\n return None\n own = set()\n for node in _ast.walk(tree):\n if isinstance(node, _ast.Name) and isinstance(node.ctx, (_ast.Store, _ast.Del)):\n own.add(node.id)\n elif isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n own.add(node.name)\n elif isinstance(node, (_ast.Import, _ast.ImportFrom)):\n own.update((alias.asname or alias.name).split(".")[0] for alias in node.names)\n calls = [\n node\n for node in _ast.walk(tree)\n if isinstance(node, _ast.Call)\n and isinstance(node.func, _ast.Name)\n and node.func.id not in own\n and node.lineno == line\n and (column is None or node.col_offset == column)\n ]\n if len(calls) != 1:\n return None\n call = calls[0]\n plain = not call.keywords and not any(isinstance(a, _ast.Starred) for a in call.args)\n return call.func.id, [_ast.get_source_segment(source, a) for a in call.args], plain\n\n\ndef _pll_a(words):\n """`words` after "a" or "an"."""\n first = words.lstrip("`")[:1].lower()\n return ("an " if first and first in "aeiou" else "a ") + words\n\n\ndef _pll_capitalized(text):\n return text[:1].upper() + text[1:]\n\n\ndef _pll_loop_over_nothing(m, e, name):\n """A `for` - or a comprehension\'s - over a value with no items."""\n over = _pll_src_re.search(r"\\bfor\\s+\\w+\\s+in\\s+len\\s*\\(\\s*([A-Za-z_][\\w.]*)\\s*\\)", e.text or "")\n if over:\n return "`len(%s)` is a number, and a number is not something to loop over." % over.group(1), [\n "To visit each item, write `for item in %s:`." % over.group(1),\n "To count, write `for i in range(len(%s)):` - but the first form is usually what you want." % over.group(1),\n ]\n if name == "NoneType":\n subject, why = _pll_why_none(e, _pll_looped_over(e.text))\n return "%s, so there is nothing to loop over." % subject, why\n looped = _pll_looped_over(e.text)\n if name == "method" and looped is not None and _pll_src_re.search(r"\\.\\w+$", looped):\n # `for p in people.rows:` - the method itself, not the rows it gives.\n return "`%s` is the method itself, not what it gives back - it has not been called." % looped, [\n "Call it, with brackets: `for ... in %s():`." % looped\n ]\n return "%s is not something to loop over." % _pll_capitalized(_pll_a_friendly(m.group(1))), [\n "`for` needs a list, a string, a range or something else with items in it."\n ]\n\n\n@_pll_stock("TypeError", r\'can only concatenate (str|list|tuple) \\(not "([\\w.]+)"\\) to \\1\')\ndef _pll_concat_mismatch(m, e):\n """`can only concatenate str (not "int") to str`, and the same for lists\n and tuples. For a string the question is which side to convert; for a\n list it is almost always one item that belongs in brackets."""\n container, other = m.groups()\n headline = "%s and %s cannot be added together." % (\n _pll_capitalized(_pll_a_friendly(container)), _pll_a_friendly(other)\n )\n line = e.text or ""\n if container == "str":\n # Built from the line when its two operands can be read: a string\n # literal on one side of the `+` and an expression on the other.\n pair = _pll_concat_operands(line)\n how_to_fix = []\n if pair is not None:\n text, value, text_first = pair\n how_to_fix.append(\n "To join them as text, convert `%s` with `str`: `%s`."\n % (value, "%s + str(%s)" % (text, value) if text_first else "str(%s) + %s" % (value, text))\n )\n if _pll_src_re.match(r"\\s*print\\s*\\(", line):\n spaced = _pll_without_joining_space(text, text_first)\n how_to_fix.append(\n "In `print`, a comma needs no conversion at all, and puts a space in for you: `print(%s)`."\n % ("%s, %s" % (spaced, value) if text_first else "%s, %s" % (value, spaced))\n )\n else:\n # `age + 1` where `age = input(...)`: the text is a number someone\n # typed, and adding to it is the point - so converting it comes\n # first, and joining as text is not the advice at all.\n typed = _pll_typed_operand(line, e.facts)\n if typed is not None:\n return headline, [\n "`%s` came from `input`, which always gives back text - even when a number is typed." % typed[0],\n "Convert it first: `%s`." % typed[1],\n ]\n how_to_fix += [\n "To join them as text, convert the other value with `str(...)`.",\n "In `print`, separate them with commas instead, which needs no conversion.",\n ]\n how_to_fix.append("To add them as numbers instead, convert the text with `int(...)` or `float(...)`.")\n return headline, how_to_fix\n # A list (or tuple) and one value: `xs + "!"`. What was meant is nearly\n # always that one value as a new item.\n operand = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\+", line)\n named = operand.group(1) if operand else "xs"\n opening, closing = ("[", "]") if container == "list" else ("(", ",)")\n how_to_fix = [\n "`+` joins a %s to another %s. To add one item, put it inside %s: `%s + %sitem%s`."\n % (container, container, "brackets" if container == "list" else "a tuple", named, opening, closing)\n ]\n if container == "list":\n how_to_fix.append(\n "Or add it in place with `%s.append(item)`, which changes `%s` and returns nothing." % (named, named)\n )\n return headline, how_to_fix\n\n\n@_pll_stock("TypeError", r"^unsupported operand type\\(s\\) for (\\S+?): \'([\\w.]+)\' and \'([\\w.]+)\'")\ndef _pll_operand_mismatch(m, e):\n """`unsupported operand type(s) for +: \'NoneType\' and \'int\'`"""\n operator, left, right = m.groups()\n if left != "NoneType" and right != "NoneType":\n return "`%s` does not work between %s and %s." % (operator, _pll_a_friendly(left), _pll_a_friendly(right)), (\n ["Convert one of them first - `int(...)` for a number, `str(...)` for text."]\n if (left == "str") != (right == "str")\n else []\n )\n # The usual cause: a function that changes something rather than\n # returning it, used as though it returned.\n side = "left" if left == "NoneType" else "right"\n subject, why = _pll_why_none(e, _pll_operand_beside(e.text, operator, side), "One of these values is `None`")\n return "%s, so `%s` cannot be used on it." % (subject, operator), why\n\n\n@_pll_stock("TypeError", r"^\'(\\S+)\' not supported between instances of \'([\\w.]+)\' and \'([\\w.]+)\'")\ndef _pll_comparison_mismatch(m, e):\n """`\'<\' not supported between instances of \'str\' and \'int\'`. The advice\n has to come from the types in hand: a fixed example such as\n `int("999")` would fit only some of the comparisons it is shown for."""\n operator, left, right = m.groups()\n text = left if left == "str" else right if right == "str" else None\n number = next(\n (_pll_last_segment(t) for t in (left, right) if _pll_last_segment(t) in ("int", "float")), None\n )\n if text is not None and number is not None:\n # The one case with an actual choice to offer: which side to convert.\n how_to_fix = [\n "Convert the text to a number with `%s(...)`, or the number to text with `str(...)` - whichever this"\n " comparison is meant to be about." % number\n ]\n elif _pll_friendly(left) == _pll_friendly(right):\n # Two images, two dictionaries: one kind, with no order to it.\n return "`%s` cannot compare one %s with another." % (operator, _pll_friendly(left)), [\n "These values have no order, so `%s` has no answer for them." % operator\n ]\n else:\n how_to_fix = ["`%s` only orders values of the same kind; there is no answer to this comparison." % operator]\n return "`%s` cannot compare %s with %s." % (operator, _pll_a_friendly(left), _pll_a_friendly(right)), how_to_fix\n\n\n@_pll_stock("ValueError", r"^invalid literal for int\\(\\) with base 10: (.+)$")\ndef _pll_bad_int_literal(m, e):\n """`invalid literal for int() with base 10: \'nineteen\'`. Nothing typed\n and a word typed are different mistakes, and when the text came from\n `input` on this line, the line says so."""\n raw = m.group(1).strip()\n empty = raw in ("\'\'", \'""\')\n # Python prints the repr, `\'nineteen\'`; the course writes `"nineteen"`.\n shown = \'"%s"\' % raw[1:-1] if _pll_src_re.fullmatch(r"\'[^\'\\"]*\'", raw) else raw\n how_to_fix = [\'`int` only reads whole numbers written in digits, like `"19"`.\']\n if _pll_src_re.search(r"\\binput\\s*\\(", e.text or ""):\n how_to_fix.append(\n "Nothing was typed: pressing Enter on its own makes `input` give back an empty string."\n if empty\n else "`input` gives back exactly what was typed, as text, so a word cannot become a number."\n )\n if empty:\n return "`int` cannot turn an empty string into a whole number.", how_to_fix\n return "`int` cannot turn %s into a whole number." % shown, how_to_fix\n\n\n@_pll_stock("IndexError", r"index out of range")\ndef _pll_index_range(m, e):\n """`list index out of range`"""\n subscript = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\[\\s*([^\\]]+?)\\s*\\]", e.text or "")\n # Python read the real length from the frame; a made-up "list of 3"\n # read as a claim about theirs.\n sequence, length = e.facts.get("sequence"), e.facts.get("length")\n if sequence is not None and length is not None:\n return "`%s` has no item at that position." % sequence, [\n "`%s` is empty, so it has no items at all." % sequence\n if length == 0\n else "`%s` has %d item%s, numbered 0 to %d - positions start at 0."\n % (sequence, length, "" if length == 1 else "s", length - 1)\n ]\n named = "`%s`" % subscript.group(1) if subscript else "This list"\n return "%s has no item at that position." % named, [\n "Positions start at 0, so the last item of a list is at position `len(...) - 1`.",\n "`len(%s)` gives the number of items." % subscript.group(1) if subscript else "`len(...)` gives the number of items.",\n ]\n\n\n@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not callable")\ndef _pll_not_callable(m, e):\n """`\'int\' object is not callable`: a value called as if it were a\n function - by its name, as `empty_image()` is, or a number before\n brackets, which is a missing `*`."""\n if m.group(1) == "types.UnionType":\n # `types.UnionType` is an implementation name for `Boa | Armadillo`.\n called = _pll_call_on(e.text)\n members = _pll_union_members_of(e, called) if called is not None else None\n return (\n "`%s` is a union of several types, not something to make one of." % called\n if called is not None\n else "This is a union of several types, not something to make one of."\n ), (\n ["Make one of %s instead, like `%s(...)`." % (_pll_list_names(members), members[0])]\n if members is not None\n else ["Make one of the types the union is built from."]\n )\n generic = "Brackets after a value mean a call; check whether a `*` or a comma is missing."\n # A name whose value was called: `empty_image()`, or `sum` made a number.\n called = e.facts.get("called")\n if called is not None:\n name = called["name"]\n headline = "`%s` is %s, not a function." % (name, _pll_a_friendly(m.group(1)))\n if called["hides"]:\n return headline, ["A variable named `%s` hides Python\'s own `%s`: give the variable a different name." % (\n name, name\n )]\n if not called["arguments"]:\n return headline, ["Leave off the brackets to use the value itself: `%s`." % name]\n return headline, [generic]\n # `3(width)` - a number followed by brackets is nearly always a missing\n # `*`. Python\'s own "perhaps you missed a comma?" points elsewhere.\n juxtaposed = _pll_src_re.search(r"(\\d+(?:\\.\\d+)?)\\s*\\(([^()]*)\\)", e.text or "")\n if juxtaposed and _pll_last_segment(m.group(1)) in ("int", "float"):\n written, number, inside = juxtaposed.group(0), juxtaposed.group(1), juxtaposed.group(2)\n return "Brackets after `%s` look like a function call, not multiplication." % number, [\n "To multiply, write the `*`: `%s * %s`, not `%s`." % (number, inside.strip() or "...", written)\n ]\n return "%s cannot be called like a function." % _pll_capitalized(_pll_a_friendly(m.group(1))), [generic]\n\n\n@_pll_stock("TypeError", r"^(\\w+)\\(\\) accepts (\\d+) positional sub-patterns \\((\\d+) given\\)")\ndef _pll_pattern_arity(m, e):\n """`Boa() accepts 2 positional sub-patterns (3 given)`"""\n class_name, accepts, given = m.group(1), int(m.group(2)), m.group(3)\n fields = _pll_fields_of(e, class_name)\n return "`%s` has %s%s, but this pattern names %s." % (\n class_name, _pll_plural(accepts, "field"), " (%s)" % _pll_list_names(fields) if fields is not None else "", given\n ), (\n ["Write one name for each field: `case %s(%s):`." % (class_name, ", ".join(fields))]\n if fields is not None\n else ["Write one name for each field, in order."]\n )\n\n\n@_pll_stock("TypeError", r"list indices must be integers or slices, not tuple")\ndef _pll_missing_comma(m, e):\n """Two lists written next to each other with no comma between them -\n `[["Jan", 1] ["Feb", 2]]` - which Python reads as looking something up\n in the first one."""\n return "A comma is missing between two values in a list.", [\n "`[a, b] [c, d]` with no comma reads as looking `c, d` up inside the first list.",\n "Write `[[a, b], [c, d]]`, with a comma after every row.",\n ]\n\n\n@_pll_stock("RecursionError", r"maximum recursion depth")\ndef _pll_recursion_depth(m, e):\n """`maximum recursion depth exceeded`: the repeated frame is the\n function that never got smaller, repeated hundreds of times."""\n counts = {}\n for frame in e.frames:\n if frame["function"] is not None:\n counts[frame["function"]] = counts.get(frame["function"], 0) + 1\n best, best_count = None, 0\n for name, count in counts.items():\n if count > best_count:\n best, best_count = name, count\n named = "`%s`" % best if best_count >= 3 else "A function"\n return "%s kept calling itself and never stopped." % named, [\n "Each call has to work on something smaller - the rest of the list, a smaller number.",\n "Check that there is a case that returns without calling again, and that it is reached.",\n ]\n\n\n@_pll_stock("ValueError", r"All arrays must be of the same length")\ndef _pll_uneven_columns(m, e):\n """pandas\' `All arrays must be of the same length`, for a DataFrame\'s dict."""\n how_to_fix = [\n "Each list in the dictionary is one column, and every column needs one value for each row - so the lists"\n " have to be the same length."\n ]\n if _pll_src_re.search(r"DataFrame\\s*\\(", e.text or ""):\n how_to_fix.append("Count the values in each list on this line; one has more or fewer than the rest.")\n return "The columns of this DataFrame have different numbers of values.", how_to_fix\n\n\n@_pll_stock("KeyError", r"^\\s*\'[\\s\\S]*\'\\s*$")\ndef _pll_pandas_key_error(m, e):\n """pandas `KeyError: \'ratings\'`, raised deep inside pandas\' indexing."""\n # A frame inside the installed library, not merely the word "pandas":\n # `samples/pandas.py` is a real file, and a plain dict `KeyError` in it\n # is not a missing DataFrame column.\n inside = any(\n _pll_src_re.search(r"[/\\\\]pandas[/\\\\]core[/\\\\]|site-packages[/\\\\]pandas", frame["file"] or "")\n for frame in e.frames\n )\n if not inside:\n return None\n key = _pll_src_re.fullmatch(r"\'([\\s\\S]*)\'", e.message.strip())\n if key is None:\n return None\n frame = _pll_src_re.search(r"([A-Za-z_][\\w.]*)\\s*\\[", e.text or "")\n return "There is no column named `%s`." % key.group(1), [\n "`print(%s.columns)` lists the columns this DataFrame has." % frame.group(1)\n if frame\n else "`.columns` lists the columns a DataFrame has.",\n "Column names are case-sensitive, and spaces count.",\n ]\n';
225
225
 
226
226
  // src/python/explain/static.py
227
227
  var static_default = '# The static checks\' findings, worded for the student: what each kind of\n# finding says, and what to do about it. `_pll_static_analyze`\n# (staticAnalysis.py) finds them; each kind\'s wording is registered here\n# under its `id`.\n\nimport re as _pll_src_re\n\n#: What each kind of static finding says, by its `id`.\n_PLL_STATIC_WORDING = {}\n\n#: The names in `_PLL_NOT_A_TYPE` that really are functions PLL provides,\n#: for the wording that says so. `image` is not one - nothing is called\n#: `image` - and `row` is a method of a table rather than a function that\n#: makes one, so both get the plain "not a type" wording.\n_PLL_FUNCTIONS_NOT_TYPES = frozenset(("table", "reactor"))\n\n\ndef _pll_static_findings(code, level, filename, session_key=None):\n """The static checks\' findings in `code`, worded: the host\'s `AnalysisFinding`s."""\n return [_pll_word_static(f, level, filename) for f in _pll_static_analyze(code, level, filename, session_key)]\n\n\ndef _pll_word_static(finding, level, filename):\n """`finding`, as `_pll_finding` made it, with what it says to the student."""\n word = _PLL_STATIC_WORDING.get(finding["id"])\n if word is None:\n headline, how_to_fix = "PLL found a problem here (`%s`) but has no explanation for it." % finding["id"], []\n else:\n headline, how_to_fix = word(finding, level)\n return {\n "id": finding["id"],\n "errorType": finding["error_type"],\n "headline": headline,\n "howToFix": how_to_fix,\n "fileName": filename,\n "lineNumber": finding["line_number"],\n "column": finding["column"],\n "nameToken": finding["name_token"],\n "severity": finding["severity"],\n "origin": "static",\n "level": level,\n }\n\n\ndef _pll_words(*kinds):\n """Register the wording of the findings of `kinds`: a function of the\n finding and the level, giving `(headline, how_to_fix)`."""\n\n def register(word):\n for kind in kinds:\n _PLL_STATIC_WORDING[kind] = word\n return word\n\n return register\n\n\n#: Where the other binding of a shadowed name is, by its scope\'s kind.\n_PLL_OUTER_SCOPE = {\n "module": "the file",\n "function": "an enclosing function",\n "lambda": "an enclosing function",\n "class": "an enclosing class",\n "comprehension": "an enclosing comprehension",\n}\n\n#: What a binding is, by how it was made.\n_PLL_BINDING_NOUN = {\n "argument": "parameter",\n "for": "loop variable",\n "functiondef": "function",\n "classdef": "class",\n "import": "import",\n "importfrom": "import",\n "capture": "name in a `case`",\n "assign": "variable",\n "annassign": "variable",\n "augassign": "variable",\n "walrus": "variable",\n "with": "variable",\n "typealias": "type",\n}\n\n#: The library a name every session starts with comes from.\n_PLL_LIBRARY_NOUN = {\n "image": "the image library",\n "table": "the table library",\n "reactor": "the reactor library",\n}\n\n\n@_pll_words("shadowing")\ndef _pll_word_shadowing(f, level):\n name = f["name_token"] or "this name"\n outer_line = f.get("outer_line_number")\n outer_noun = _PLL_OUTER_SCOPE.get(f.get("outer_scope_kind"), "an outer scope")\n known = outer_line is not None and outer_line > 0\n inner = _PLL_BINDING_NOUN.get(f.get("binding"), "name")\n outer = _PLL_BINDING_NOUN.get(f.get("outer_binding"), "name")\n # The other one comes later in the file, so it was not "already" there:\n # the two simply share a name, and either can change.\n if known and f["line_number"] is not None and outer_line > f["line_number"]:\n return "`%s` names two things: this %s, and a %s in %s, on line %d." % (\n name, inner, outer, outer_noun, outer_line\n ), [\n "Give one of them another name - this %s, or the %s on line %d - so each name means one thing."\n % (inner, outer, outer_line)\n ]\n where = " (first defined on line %d, in %s)" % (outer_line, outer_noun) if known else " in %s" % outer_noun\n return "`%s` is already defined%s." % (name, where), [\n "Rename this %s to something distinct (e.g. `%s_inner`, `local_%s`, or whatever describes its role)."\n % (inner, name, name),\n "Or, if you want to use the outer `%s`, just read it directly without making a new %s."\n % (name, inner if inner in ("parameter", "type") else "variable"),\n ]\n\n\n@_pll_words("shadowing-builtin")\ndef _pll_word_shadowing_builtin(f, level):\n name = f["name_token"] or "this name"\n if f.get("binding") == "typealias":\n return "`%s` is the name of a Python built-in." % name, [\n "Give the type a name of its own that says what it stands for. Type names are capitalized, like"\n " `Shape` or `NumList`."\n ]\n return "`%s` is the name of a Python built-in." % name, [\n "Pick a different name. Common patterns: `my_%s`, `%s_value`, `%ss` (plural), or a more specific noun"\n " describing what this value represents." % (name, name, name)\n ]\n\n\n@_pll_words("shadowing-library")\ndef _pll_word_shadowing_library(f, level):\n """A name every session starts with, like `circle` or `table`."""\n name = f["name_token"] or "this name"\n noun = _PLL_LIBRARY_NOUN.get(f.get("library"), "the PLL libraries")\n binding = f.get("binding")\n if binding in ("import", "importfrom"):\n # The fix is the import\'s own `as`, not a rename of anything of theirs.\n module = f.get("module") or "module"\n alias = module.split(".")[0] + name[:1].upper() + name[1:]\n written = (\n "from %s import %s as %s" % (module, name, alias)\n if binding == "importfrom"\n else "import %s as %s" % (name, alias)\n )\n return "`%s` is already defined by %s, and this import replaces it." % (name, noun), [\n "Import it under another name: `%s`, and use `%s` where you mean that one." % (written, alias)\n ]\n if binding in ("argument", "for"):\n what = _PLL_BINDING_NOUN[binding]\n return "`%s` is already a name in PLL - one of %s\'s - and this %s hides it." % (name, noun, what), [\n "Give the %s another name - for example `%s_value`, or a word for what it holds." % (what, name)\n ]\n if binding == "typealias":\n fixes = ["Give your type a name of its own that says what it stands for."]\n if f.get("library_type"):\n fixes.append("If you meant the library\'s `%s`, use it as it is: it is already a type." % name)\n return "`%s` is already defined by %s." % (name, noun), fixes\n return "`%s` is already defined by %s." % (name, noun), [\n "Pick a different name for your definition - for example `my_%s`, `%s_value`, or a word that describes"\n " what yours represents." % (name, name),\n "If you meant to use the library\'s `%s`, call it directly instead of defining a new `%s` of your own."\n % (name, name),\n ]\n\n\n@_pll_words("reassignment")\ndef _pll_word_reassignment(f, level):\n name = f["name_token"] or "this name"\n first = f.get("first_line_number")\n # Line 0 is a binding the session already had: a prompt line after Run File.\n where = " (first assigned on line %d)" % first if first is not None and first > 0 else ""\n headline = "`%s` is already assigned%s." % (name, where)\n if f.get("type_alias"):\n # A `type` is not a running total: the fix is one meaning for the name.\n return headline, [\n "A type keeps one meaning: give the type a name of its own, or leave out the other line that sets `%s`."\n % name\n ]\n rename = "If you want a different value, give it a different name (e.g. `%s_doubled`, `new_%s`, `%s2`)." % (\n name, name, name\n )\n # Refused here, but not inside a function: then the fix is to move the\n # work into one.\n if not _pll_refuses_reassignment(level, "function"):\n return headline, [\n rename,\n "If you\'re trying to update a value (`x = x + 1`, accumulators, running totals), wrap the work in a"\n " function and rebind `%s` from the function\'s return value: `%s = compute_%s(...)`." % (name, name, name),\n ]\n fixes = [\n rename,\n "If you\'re trying to update a value (`x = x + 1`, accumulators, running totals), use a built-in like"\n " `sum(...)`, `max(...)`, or a small helper function that returns the new value instead.",\n ]\n # Inside a function this is exactly what `intermediate` exists for, and\n # a student has no way to know that from here.\n if f.get("scope_kind") == "function" and not _pll_refuses_reassignment(_PLL_LEVEL_INTERMEDIATE, "function"):\n fixes.append(\n "`#level intermediate` allows changing a variable inside a function, which is what a running total needs."\n )\n return headline, fixes\n\n\n@_pll_words("duplicate-definition")\ndef _pll_word_duplicate_definition(f, level):\n """Two `def`s, two `class`es or two `type`s with the same name.\n\n Python keeps the second and throws the first away silently, which is how\n a test copied without renaming it disappears. Its own finding, not a\n reassignment: that advice is about variables, and the fix here is to\n rename one of them.\n """\n name = f["name_token"] or "this name"\n kind = {"class": "classes", "type": "types"}.get(f.get("definition_kind"), "functions")\n first = f.get("first_line_number")\n where = " (lines %s and %s)" % (first, f["line_number"]) if isinstance(first, int) and first > 0 else ""\n return "There are two %s named `%s`%s." % (kind, name, where), [\n "Rename one of them. Python keeps only the second, so the first %s."\n % ("is never used" if kind == "types" else "never runs"),\n "A test copied and not renamed vanishes this way: only the last one is run."\n if kind == "functions" and name.startswith("test_")\n else "Both definitions are kept in the file, but only the last name points at anything.",\n ]\n\n\n@_pll_words("disallowed-keyword")\ndef _pll_word_disallowed_keyword(f, level):\n """`global` or `nonlocal` at a level that refuses them: each lets a\n function rebind a name in an enclosing scope, which is the action at a\n distance both levels keep beginners from."""\n keyword = "nonlocal" if f.get("keyword") == "nonlocal" else "global"\n names = ", ".join("`%s`" % n for n in f.get("names") or [])\n named = " " + names if names else ""\n if keyword == "global":\n fixes = [\n "Pass%s into this function as an argument instead of declaring it `global`." % (named or " the value"),\n "If the function needs to *change* a value, return the new value and let the caller decide what to do"\n " with it (e.g. `x = update(x)`).",\n ]\n else:\n fixes = [\n "Return%s from this inner function and have the outer function rebind it explicitly (e.g. `x = inner(x)`)."\n % (named or " the new value"),\n "If the inner function only needs to *read* the outer name, just use it directly - you don\'t need"\n " `nonlocal` for that.",\n ]\n # The level that does allow it. Without this the finding says what is\n # forbidden and never what to do if the student really does want it.\n fixes.append("`#level advanced` allows `%s`, if you have a reason to use it." % keyword)\n return "`%s` is not allowed at the %s level." % (keyword, level), fixes\n\n\n@_pll_words("unbinding")\ndef _pll_word_unbinding(f, level):\n """`del x` at a level that refuses it. Taking an item out of a value,\n `del d[key]`, is allowed, so the wording says that too."""\n names = f.get("names") or [f["name_token"]]\n headline = "`del` removes the name%s %s, which is not allowed at the %s level." % (\n "s" if len(names) > 1 else "", _pll_list_names(names), level\n )\n if f.get("loop_variable"):\n fixes = [\n "If this is meant to leave the item out: `del %s` removes only the name `%s`, not the item from what the"\n " loop goes through. Build a new list of the items to keep instead." % (names[0], names[0])\n ]\n else:\n fixes = [\n "Leave %s in place: nothing needs a name removed%s." % (\n _pll_list_names(names),\n ", and a function\'s names go away by themselves when it returns"\n if f.get("scope_kind") == "function" else "",\n ),\n "`del` can still take an item out of a dictionary or a list: `del d[key]`, `del items[0]`.",\n ]\n fixes.append("`#level advanced` allows removing a name with `del`, if you have a reason to.")\n return headline, fixes\n\n\nclass _PllNameReading:\n """An `undefined-name` finding, read as the error it foresees: what the\n rules that word a NameError as it happens (`_pll_name_wording`) read."""\n\n def __init__(self, f):\n self.name = f["name_token"]\n self.text = f.get("text")\n self.message = f.get("says") or ""\n self.facts = {\n key: f[key]\n for key in ("defined_on", "deleted_on", "except_on", "being_defined", "called_on", "called", "in_annotation")\n if key in f\n }\n self.source = ""\n self.info = {"lineNumber": f["line_number"]}\n\n\n@_pll_words("undefined-name")\ndef _pll_word_undefined_name(f, level):\n """A name read where it has no value: worded as the error a run would\n raise there, so that the same mistake reads the same before and during."""\n return _pll_name_wording(_PllNameReading(f))\n\n\n# Code that would otherwise run without a word being said. Each is valid\n# Python that does nothing, or something other than what was meant: a test\n# written without `assert` always passes; an `assert` on a tuple is always\n# true; a method named but not called is the method itself; an annotation\n# naming a function checks nothing. Without a finding the student\'s only\n# evidence is a program that seems to work.\n\n\n@_pll_words("unused-comparison")\ndef _pll_word_unused_comparison(f, level):\n """`pen_cost(0, "huskies") == 1` on a line of its own: in a test, an\n `assert` left out; in another function, most often a `return` - the\n last line of a Pyret function is its answer, and a Python one\'s is not."""\n expression = f.get("expression")\n # Their own comparison, not `assert ...`.\n shown = "`assert %s`" % expression if expression else "`assert ...`"\n if f.get("in_test") or not expression:\n return "This comparison\'s answer is not used, so nothing checks it.", [\n "Did you mean %s? Without `assert` the comparison is worked out and thrown away, so the test always passes."\n % shown\n ]\n return "This comparison\'s answer is not used.", [\n "Did you mean `return %s`? A function gives back only what it `return`s." % expression,\n "Or, to check it, %s." % shown,\n ]\n\n\n@_pll_words("unused-value")\ndef _pll_word_unused_value(f, level):\n """`ac.balance + amt` on a line of its own."""\n expression = f.get("expression")\n constant = f.get("constant") and expression is not None\n headline = (\n "This line is only the value `%s`, which goes nowhere." % expression\n if constant\n else "This line works out a value and then throws it away."\n )\n if f.get("in_test"):\n return headline, ["A test checks with `assert`: compare the value with what it should be."]\n # `ac.balance + amt` reads a field and adds to it: assigning the result\n # back to that field is nearly always what was meant.\n field = _pll_src_re.match(r"([A-Za-z_]\\w*\\.[A-Za-z_]\\w*)\\s*[-+*/]", expression) if expression is not None else None\n if expression is None:\n fixes = ["Use it: `return` it, assign it to a name, or pass it to `print`."]\n elif field:\n fixes = [\n "To change `%s`, assign the result to it: `%s = %s`." % (field.group(1), field.group(1), expression),\n "Or, if this is the function\'s answer, return it: `return %s`." % expression,\n ]\n elif constant:\n fixes = ["Did you mean `return %s`? A function gives back only what it `return`s." % expression]\n else:\n fixes = ["Did you mean `return %s`? On its own, the value is worked out and thrown away." % expression]\n return headline, fixes + ["Nothing in Python changes as a result of this line."]\n\n\n@_pll_words("missing-return")\ndef _pll_word_missing_return(f, level):\n """`def area(r: float) -> float:` with no `return` in it - worded as the\n run\'s type check words the `None` it gives back."""\n name = f["name_token"] or "this function"\n written = f.get("returns") or ""\n wanted = _pll_describe_type(written) if _pll_src_re.fullmatch(r"[A-Za-z_]\\w*", written) else "`%s`" % written\n headline = "`%s` should return %s, but it has no `return`, so it always gives back `None`." % (name, wanted)\n printed = f.get("printed")\n if printed is not None:\n return headline, [\n "Line %d ends it with `print`: did you mean `return %s`?" % (f["printed_line"], printed),\n "`print` shows a value on screen; `return` hands it back to whatever called the function.",\n ]\n return headline, [\n "End it with `return` and the value it works out.",\n "Or annotate the return type as `None` if `%s` is not meant to return anything." % name,\n ]\n\n\n@_pll_words("assert-tuple")\ndef _pll_word_assert_tuple(f, level):\n """`assert(x, 1)`: a tuple, which is always true."""\n return "This `assert` has brackets around two things, so it is always true.", [\n "`assert(a, b)` checks a pair, and a pair is never false - the test passes whatever happens.",\n "Write `assert a == b`, with no brackets after `assert`.",\n ]\n\n\n@_pll_words("is-literal")\ndef _pll_word_is_literal(f, level):\n """`x is 60.5`: `is`, where `==` was meant."""\n negated = f.get("operator") == "is not"\n literal = f.get("literal") or "a value"\n return "`%s` asks whether two things are the very same object, not whether they are equal." % (\n "is not" if negated else "is"\n ), [\n "To compare with `%s`, write `%s` instead." % (literal, "!=" if negated else "=="),\n "`is` is for `None`: `x is None`.",\n ]\n\n\n@_pll_words("method-not-called")\ndef _pll_word_method_not_called(f, level):\n """`movies["rating"].mean`: the method, not its result."""\n method = f["name_token"] or "the method"\n return "`%s` is named here but never called." % method, [\n "Add the brackets: `.%s()`." % method,\n "Without them this is the method itself, which displays as `<bound method ...>`.",\n ]\n\n\n@_pll_words("annotation-not-a-type")\ndef _pll_word_annotation_not_a_type(f, level):\n """`t: table`: the function that makes tables, not the type."""\n written = f["name_token"] or ""\n type_name = _PLL_NOT_A_TYPE.get(written)\n # What goes wrong depends on whether the name exists. `table` is a real\n # function, so the annotation is accepted and silently checks nothing;\n # `string` and `Float` are not names at all, so the line fails with a\n # NameError the moment it runs - and saying "accepted" there is false.\n exists = written in _PLL_FUNCTIONS_NOT_TYPES\n headline = (\n "`%s` is the function that makes a %s; the type is `%s`." % (written, written, type_name)\n if exists\n else "`%s` is not a type Python knows." % written\n )\n if type_name is None:\n return headline, ["Nothing about this value is being checked as a result."]\n return headline, _pll_type_fix(written) + [\n "As it stands the annotation is accepted and nothing about this value is checked."\n if exists\n else "There is nothing called `%s`, so this line fails as soon as it runs." % written,\n ]\n\n\n@_pll_words("test-not-named")\ndef _pll_word_test_not_named(f, level):\n """A function with an `assert` in it that nothing ever runs."""\n name = f["name_token"] or "this function"\n return "`%s` has an `assert` in it, but nothing ever runs it." % name, [\n "Rename it `test_%s` and it will run with the other tests." % name,\n "Only functions whose names start with `test_` are run automatically.",\n ]\n\n\n@_pll_words("field-no-type")\ndef _pll_word_field_no_type(f, level):\n """`year` on a line of its own in a dataclass body."""\n name = f["name_token"] or "this field"\n return "The field `%s` has no type, so it is not a field at all." % name, [\n "Every field of a dataclass needs a type: `%s: int`, `%s: str`, and so on." % (name, name),\n # Not the NameError it would lead to: this is reported before the\n # program runs, so that error is never reached.\n "On a line of its own, `%s` only uses the name; it does not declare anything." % name,\n ]\n\n\n@_pll_words("field-assigned-type")\ndef _pll_word_field_assigned_type(f, level):\n """`year = int` where `year: int` was meant."""\n name = f["name_token"] or "this field"\n type_name = f.get("written_type") or "int"\n return "`%s = %s` sets `%s` to the type itself; did you mean `%s: %s`?" % (\n name, type_name, name, name, type_name\n ), [\n "A field is declared with a colon, not an `=`.",\n "With an `=` there is no `%s` field, and the constructor ends up with the wrong number of arguments." % name,\n ]\n\n\n@_pll_words("class-needs-dataclass")\ndef _pll_word_class_needs_dataclass(f, level):\n """A class with annotated fields and no `@dataclass`."""\n name = f["name_token"] or "this class"\n return "`%s` lists fields but has no `@dataclass`, so `%s(...)` takes no arguments." % (name, name), [\n "Write `@dataclass` on the line above `class %s`." % name,\n "Add `from dataclasses import dataclass` at the top of the file if it is not there.",\n ]\n\n\n@_pll_words("compared-with-class")\ndef _pll_word_compared_with_class(f, level):\n """`if a == Boa:`: a value is never equal to the class it was made from."""\n name = f["name_token"] or "a class"\n return "`%s` is a class, so comparing a value with it is always False." % name, [\n "Use `match` to tell the kinds apart: `case %s(...):`." % name,\n "`%s` is the blueprint; a value made from it is never equal to it." % name,\n ]\n\n\n# -- a `case` pattern\'s class --------------------------------------------------\n\n\ndef _pll_pattern_variants(subject, union, members):\n """"an `Animal` is a `Boa` or an `Armadillo`", or "`a` is ..." for a\n union written in the annotation itself."""\n kinds = _pll_listed([_pll_with_article("`%s`" % m) for m in members], "or")\n return "%s is %s" % (_pll_with_article("`%s`" % union) if union else "`%s`" % subject, kinds)\n\n\ndef _pll_pattern_fixes(name, subject, union, members):\n """Match one of them, or make `name` one of them."""\n classes = [m for m in members if m != "None"]\n cases = _pll_listed(["`case %s(...)`" % m for m in classes], "or")\n widened = " | ".join(members + [name])\n added = (\n "If %s is a new kind of `%s`, define `class %s` and add it: `%s = %s`."\n % (_pll_with_article("`%s`" % name), union, name, union, widened)\n if union\n else "If `%s` can be %s too, define `class %s` and add it to the annotation: `%s`."\n % (subject, _pll_with_article("`%s`" % name), name, widened)\n )\n return ["Match %s: %s." % ("one of them" if len(classes) > 1 else "it", cases), added]\n\n\ndef _pll_pattern_class_wording(name, subject, union, members, classes):\n """A `case` pattern naming a class nothing defines: what the subject can\n be, when its annotation says, or else the classes the program has."""\n if members:\n close = _pll_closest_name(name, [m for m in members if m != "None"])\n # A misspelling: the one answer, not the ways round it.\n return "There is no class `%s`: %s." % (name, _pll_pattern_variants(subject, union, members)), (\n ["Did you mean `%s`?" % close] if close else _pll_pattern_fixes(name, subject, union, members)\n )\n headline = "There is no class `%s` for this `case` to match." % name\n close = _pll_closest_name(name, classes)\n if close:\n return headline, ["Did you mean `%s`?" % close]\n return headline, (["The classes this program has are %s." % _pll_list_names(classes)] if classes else []) + [\n "If `%s` should be a class of its own, define it: `class %s:`." % (name, name)\n ]\n\n\n@_pll_words("unknown-pattern-class")\ndef _pll_word_unknown_pattern_class(f, level):\n """`case Aardvark(b):` with no `Aardvark` anywhere: a NameError whenever\n the case is tried, which a program can go a long time without doing."""\n return _pll_pattern_class_wording(f["name_token"], f["subject"], f["union"], f["members"], f["classes"])\n\n\n@_pll_words("pattern-never-matches")\ndef _pll_word_pattern_never_matches(f, level):\n """`case Circle():` on a value annotated `Light`: a class it is never."""\n name = f["name_token"]\n return "This `case` never matches: %s, never %s." % (\n _pll_pattern_variants(f["subject"], f["union"], f["members"]), _pll_with_article("`%s`" % name)\n ), _pll_pattern_fixes(name, f["subject"], f["union"], f["members"])\n';
@@ -230,7 +230,7 @@ var static_default = '# The static checks\' findings, worded for the student: wh
230
230
  var image_default = '# PLL image library.\n#\n# A small, immutable, HtDP-style image library inspired by Racket\'s\n# `2htdp/image`. Every primitive and combinator returns a new `Image`;\n# nothing mutates.\n#\n# An image is a tree: shapes at the leaves, `_Group`s above them. Each node\n# knows its size when it is made, and a group where its children sit inside\n# it. Drawing walks the tree once, with a stack rather than recursion, into a\n# flat list of shapes in the picture\'s own coordinates (`_pll_flatten`).\n# That list is what is rendered as SVG, what `rotate` measures its box from,\n# and what `==` compares: two images are equal when they draw the same\n# shapes, in the same places, in the same colours.\n#\n# The install step copies the public names into each session\'s globals, so\n# a beginner can write `circle(50, "solid", "red")` with no import.\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_color_words = iter("""\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())\n_PLL_CSS_COLORS = {name: tuple(bytes.fromhex(code)) for name, code in zip(_pll_color_words, _pll_color_words)}\ndel _pll_color_words\n\n#: A colour written as text, besides a name: `#rgb`, `#rrggbb`, and\n#: `rgb(...)`, `rgba(...)`, `hsl(...)` and `hsla(...)` with commas.\n_PLL_COLOR_HEX_RE = _pll_img_re.compile(r"#([0-9a-f]{3}|[0-9a-f]{6})")\n_PLL_COLOR_FN_RE = _pll_img_re.compile(r"(rgb|hsl)a?\\(([^()]*)\\)")\n_PLL_COLOR_NUMBER_RE = _pll_img_re.compile(r"[+-]?(?:\\d+\\.?\\d*|\\.\\d+)")\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 self.key = tuple(rgb) + (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 inside the\n rendering code after the broken picture has been displayed.\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, least=None, above=None, whole=False):\n """`value` as a number: a float, or an int when it has to be `whole`;\n at least `least` and more than `above`, when they are given.\n\n A string size (`rectangle("20", 20, ...)`), a negative one, NaN and the\n infinities would each draw the wrong thing or nothing, or fail much\n later in Python\'s words, so each is refused here, naming the argument.\n """\n\n def refuse(kind, says):\n return kind(\n "%s\'s `%s`%s %s.%s" % (who, param, _pll_where(who, param), says, _pll_contract(who))\n )\n\n if not _pll_real(value):\n raise refuse(TypeError, "must be a number, but it is %s" % _pll_describe(value))\n try:\n number = float(value)\n except OverflowError:\n number = _math.inf\n if not _math.isfinite(number):\n raise refuse(ValueError, "has to be a finite number, but it is %s" % _pll_number(number))\n if whole:\n if not isinstance(value, _pll_img_numbers.Integral):\n raise refuse(\n TypeError,\n "has to be a whole number, but it is %r%s"\n % (number, ": write %d" % number if number.is_integer() else ""),\n )\n number = int(value)\n if least is not None and number < least:\n raise refuse(\n ValueError,\n "cannot be %s, but it is %s"\n % ("negative" if least == 0 else "less than %s" % _pll_number(least), _pll_number(value)),\n )\n if above is not None and number <= above:\n raise refuse(ValueError, "has to be more than %s, but it is %s" % (_pll_number(above), _pll_number(value)))\n return number\n\n\ndef _pll_check_order(first, second, who, param):\n """The two arguments the other way round.\n\n `rotate(image, 45)` would fail inside the arithmetic, naming an internal\n 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\n_PLL_COLOR_FORMS = \' Use a name like "red", a hex code like "#ff0000", or (red, green, blue) numbers from 0 to 255.\'\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 when the shape is made, so the error points\n 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. Any\n other text is turned into numbers, which are checked as a tuple is.\n """\n numbers = color\n if isinstance(color, str):\n text = color.strip().lower()\n if text in ("transparent", "none"):\n return _PLL_TRANSPARENT\n if text in _PLL_CSS_COLORS:\n return _Paint(text, _PLL_CSS_COLORS[text], 1.0)\n numbers = _pll_color_text(text)\n # "light blue", "light-blue": CSS writes every name as one word.\n squeezed = _pll_img_re.sub(r"[\\s_-]+", "", text)\n if numbers is None and _pll_img_re.fullmatch("[a-z]+", squeezed):\n suggestion = squeezed if squeezed in _PLL_CSS_COLORS else _pll_closest_name(squeezed, _PLL_CSS_COLORS)\n raise ValueError(\n "%s\'s `color`%s is %s, which is not a color name PLL knows.%s"\n % (\n who,\n _pll_where(who, "color"),\n _pll_describe(color),\n \' Did you mean "%s"?\' % suggestion if suggestion is not None else _PLL_COLOR_FORMS,\n )\n )\n # The fourth number is the opacity, told by its type: a whole number\n # runs from 0 to 255 like the others, a fraction from 0.0 to 1.0. So\n # `(255, 0, 0, 128)` and `(255, 0, 0, 0.5)` are both half see-through.\n if (\n isinstance(numbers, (tuple, list))\n and len(numbers) in (3, 4)\n and all(_pll_real(n) and 0 <= n <= 255 for n in numbers)\n ):\n alpha = numbers[3] if len(numbers) == 4 else 1.0\n alpha = int(alpha) / 255.0 if isinstance(alpha, _pll_img_numbers.Integral) else float(alpha)\n if alpha <= 1:\n rgb = [int(round(float(n))) for n in numbers[:3]]\n return _Paint(_pll_hex_css(rgb), rgb, alpha)\n shown = repr(color) if isinstance(color, (tuple, list)) and len(color) <= 4 else _pll_describe(color)\n raise ValueError(\n "%s\'s `color`%s is %s, which is not a color.%s" % (who, _pll_where(who, "color"), shown, _PLL_COLOR_FORMS)\n )\n\n\n\ndef _pll_color_text(text):\n """The numbers a colour written as text stands for, or None."""\n found = _PLL_COLOR_HEX_RE.fullmatch(text)\n if found:\n digits = found.group(1)\n if len(digits) == 3:\n digits = "".join(d * 2 for d in digits)\n return tuple(bytes.fromhex(digits))\n found = _PLL_COLOR_FN_RE.fullmatch(text)\n if not found:\n return None\n parts = [part.strip() for part in found.group(2).split(",")]\n if len(parts) not in (3, 4):\n return None\n hsl = found.group(1) == "hsl"\n if hsl:\n # Saturation and lightness are percentages.\n if not (parts[1].endswith("%") and parts[2].endswith("%")):\n return None\n parts[1], parts[2] = parts[1][:-1], parts[2][:-1]\n if not all(_PLL_COLOR_NUMBER_RE.fullmatch(part) for part in parts):\n return None\n numbers = [float(part) for part in parts]\n if hsl:\n hue, sat, light = numbers[:3]\n if not (0 <= sat <= 100 and 0 <= light <= 100):\n return None\n numbers[:3] = _pll_hsl_to_rgb(hue % 360, sat / 100, light / 100)\n return tuple(numbers)\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_check_mode(mode, who):\n """`solid` or `outline`, and nothing else: falling through to solid\n would quietly fill the shape for a misspelled "outilne"."""\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, which would be an\n 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_edges(points):\n """Each corner of a polygon with the next one round."""\n return zip(points, points[1:] + points[:1])\n\n\ndef _pll_area(points):\n """Twice the signed area; positive when the points turn one way."""\n total = 0.0\n for (x1, y1), (x2, y2) in _pll_edges(points):\n total += x1 * y2 - x2 * y1\n return total\n\n\ndef _pll_near(p, q):\n return abs(p[0] - q[0]) <= 1e-9 and abs(p[1] - q[1]) <= 1e-9\n\n\ndef _pll_distinct(points):\n """The corners, without one repeated beside itself or at both ends."""\n out = []\n for p in points:\n if not out or not _pll_near(p, out[-1]):\n out.append(p)\n while len(out) > 1 and _pll_near(out[0], out[-1]):\n out.pop()\n return out\n\n\ndef _pll_convex_and(subject, clipper):\n """The part of convex polygon `subject` inside convex polygon `clipper`:\n Sutherland-Hodgman, edge by edge. Empty when they do not meet."""\n turn = 1.0 if _pll_area(clipper) >= 0 else -1.0\n out = list(subject)\n for (ax, ay), (bx, by) in _pll_edges(clipper):\n\n def side(p):\n return turn * ((bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax))\n\n given, out = out, []\n for prev, cur in zip(given[-1:] + given[:-1], given):\n prev_side, cur_side = side(prev), side(cur)\n # Where the edge crosses the clipper\'s line, then the corner if inside.\n if (prev_side < -1e-9) != (cur_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 if cur_side >= -1e-9:\n out.append(cur)\n kept = _pll_distinct(out)\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 return all(\n turn * ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) >= -1e-6\n for (ax, ay), (bx, by) in _pll_edges(polygon)\n for px, py in points\n )\n\n\n# An item is one shape, ready to draw, in the coordinates of the picture\n# being drawn: its kind, the convex polygon it is clipped to (or None), its\n# paint, and the kind\'s own geometry:\n#\n# ("poly", clip, paint, mode, points)\n# ("ellipse", clip, paint, mode, cx, cy, rx, ry, degrees) rx along degrees\n# ("line", clip, paint, x1, y1, x2, y2)\n# ("text", clip, paint, matrix, width, height, text, size)\n# ("picture", clip, paint, matrix, width, height, picture) paint opaque\n#\n# The edges of a crop or a scene are a transparent polygon: nothing is drawn,\n# but `rotate` measures it, as 2htdp 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":\n return _pll_bounds(item[4])\n if kind == "ellipse":\n cx, cy, rx, ry, degrees = item[4:]\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[3:5], item[5:7]))\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 return _pll_bounds(_pll_corners(*item[3:6]))\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 if node._children 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(node._children):\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\n Its corners, sorted: every polygon PLL draws is convex or star-shaped\n about its centre, so its corners alone say what it is.\n """\n return tuple(sorted(set(map(_pll_point_key, points))))\n\n\ndef _pll_item_key(item):\n """What `==` compares of one item."""\n kind, clip, paint = item[:3]\n if kind == "poly":\n shape = (item[3], _pll_polygon_key(item[4]))\n elif kind == "ellipse":\n mode, cx, cy, rx, ry, degrees = item[3:]\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 = (mode, _pll_r(cx), _pll_r(cy), _pll_r(big), _pll_r(small), axis)\n elif kind == "line":\n shape = tuple(sorted((_pll_point_key(item[3:5]), _pll_point_key(item[5:7]))))\n else:\n m, w, h = item[3:6]\n corners = tuple(_pll_point_key(_pll_apply(m, x, y)) for x, y in ((0, 0), (w, 0), (0, h)))\n shape = (corners, item[6]) if kind == "text" else (corners, item[6]._media_type, item[6]._data)\n return (kind, paint.key, shape, _pll_polygon_key(clip) if clip else ())\n\n\ndef _pll_visible(item):\n return item[2].alpha > 0 and not (item[0] == "text" and item[6] == "")\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_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 = _pll_distinct(points)\n area = _pll_area(pts)\n if len(pts) < 3 or abs(area) < 1e-9:\n return None\n turn = 1.0 if area > 0 else -1.0\n edges = []\n for (px, py), (qx, qy) in _pll_edges(pts):\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 # Each corner where the edges either side of it, moved in, meet.\n out = []\n for (ax, ay, adx, ady), (bx, by, bdx, bdy) in zip(edges[-1:] + edges[:-1], edges):\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 else:\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 # Gone inside out: an edge that now runs the other way.\n for ((px, py), (qx, qy)), edge in zip(_pll_edges(out), edges):\n if (qx - px) * edge[2] + (qy - py) * edge[3] <= 0:\n return None\n return out\n\n\ndef _pll_svg_poly(item):\n _, _, paint, mode, points = item\n rect = _pll_axis_rect(points)\n if mode == "outline":\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 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 _, _, paint, mode, cx, cy, rx, ry, degrees = 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 _, _, paint, x1, y1, x2, y2 = 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 _, _, paint, m, w, h, text, size = item\n if w <= 0:\n return ""\n return (\n \'<text x="0" y="%s" font-family="monospace" font-size="%s" textLength="%s" \'\n \'lengthAdjust="spacingAndGlyphs" xml:space="preserve"%s transform="%s">%s</text>\'\n ) % (\n _pll_n(size * _PLL_TEXT_BASELINE), _pll_n(size), _pll_n(w), _pll_paint(paint, "fill"),\n _pll_matrix(m), _pll_xml_escape(text),\n )\n\n\n_PLL_SVG_SHAPES = {"poly": _pll_svg_poly, "ellipse": _pll_svg_ellipse, "line": _pll_svg_line, "text": _pll_svg_text}\n\n\ndef _pll_render(items):\n """The SVG elements that draw `items`, with whatever `<defs>` they need.\n\n A picture is embedded once, however many times it is drawn, each with\n `<use>`; shapes that share a clip share one group.\n """\n defs = []\n body = []\n pictures = {}\n clip = None\n for item in items:\n if item[2].alpha == 0:\n continue\n if item[0] == "picture":\n picture = item[6]\n if id(picture) not in pictures:\n pictures[id(picture)] = _pll_svg_id("i")\n defs.append(\n \'<image id="%s" width="%s" height="%s" preserveAspectRatio="none" href="%s"/>\'\n % (pictures[id(picture)], _pll_n(item[4]), _pll_n(item[5]), picture._href())\n )\n markup = \'<use href="#%s" transform="%s"/>\' % (pictures[id(picture)], _pll_matrix(item[3]))\n else:\n markup = _PLL_SVG_SHAPES[item[0]](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# Images: the base class, the shapes and the group\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 #: A group\'s `(child, transform, clip)` for each child, bottom first;\n #: None for a shape, which has `_items` instead.\n _children = None\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 _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 {"kind": "image", "svg": self.to_svg(), "width": _pll_px(self._w), "height": _pll_px(self._h)}\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, and\n in Python\'s words would name PLL\'s private classes."""\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\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, self._paint, self._mode, tuple(_pll_apply(m, x, y) for x, y in self._points)),)\n\n\ndef _pll_rectangle(width, height, mode, paint):\n return _Polygon(((0, 0), (width, 0), (width, height), (0, height)), mode, paint)\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 _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 """The ellipse drawn with `m`: another ellipse, whose radii and turn\n are the singular values and rotation of the transform\'s linear part\n (a closed form for 2x2)."""\n rx, ry = self._w / 2, self._h / 2\n x, y = _pll_apply(m, rx, ry)\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, self._paint, self._mode, x, y, q + r, abs(q - r), _math.degrees(turn)),)\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, self._paint, ax, ay, bx, by),)\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, self._paint, m, self._w, self._h, self._text, self._size),)\n\n\nclass _Picture(Image):\n """A picture made elsewhere - a file, a URL, a chart - 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 (("picture", clip, _PLL_BLACK, 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\n#: While a group is being freed, the children of the groups inside it, each\n#: waiting its turn (`_Group.__del__`).\n_pll_freeing = None\n\n\nclass _Group(Image):\n """Pictures put together: each child with the transform that places it\n (None for none) and the region it is clipped to (or None), bottom first."""\n\n def __init__(self, width, height, children):\n self._w = width\n self._h = height\n self._children = children\n\n def __del__(self):\n """The groups inside freed one at a time rather than each inside the\n last: freed the usual way, a picture a few thousand groups deep runs\n out of the browser\'s stack, which ends Python."""\n global _pll_freeing\n # Straight into the list, with no name of their own here: one would\n # keep them until this returns, and free them inside it.\n if _pll_freeing is not None:\n _pll_freeing.append(self.__dict__.pop("_children", None))\n return\n _pll_freeing = [self.__dict__.pop("_children", None)]\n try:\n while _pll_freeing:\n _pll_freeing.pop()\n finally:\n _pll_freeing = None\n\n\n# -----------------------------------------------------------------------------\n# Loading a picture from a file or a URL\n# -----------------------------------------------------------------------------\n\ndef _pll_jpeg_size(data, number):\n """The size in a JPEG\'s frame header, found by walking its segments."""\n i = 2\n while i + 3 < len(data):\n marker = data[i + 1]\n if data[i] != 0xFF:\n i += 1\n elif marker in (0x01, 0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:\n # Standalone: no length to skip.\n i += 2\n elif 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):\n # A frame header: any SOFn but the three that are not.\n return (number(i + 7, i + 9), number(i + 5, i + 7)) if i + 9 <= len(data) else None\n else:\n i += 2 + max(number(i + 2, i + 4), 2)\n return None\n\n\ndef _pll_webp_size(data, number):\n kind = data[12:16]\n if kind == b"VP8 ":\n return (number(26, 28) & 0x3FFF, number(28, 30) & 0x3FFF)\n if kind == b"VP8L":\n bits = number(21, 25)\n return ((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)\n if kind == b"VP8X":\n return (number(24, 27) + 1, number(27, 30) + 1)\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 # Past the prologue: comments can hold anything, an `<svg` included.\n head = _pll_img_re.sub(r"<!--.*?-->", "", data[:65536].decode("utf-8", "replace"), flags=_pll_img_re.S)\n tag = _PLL_SVG_TAG_RE.search(head)\n if tag is None:\n return None\n attrs = {name.lower(): double or single for name, double, single in _PLL_SVG_ATTR_RE.findall(tag.group(1))}\n\n def length(name):\n found = _PLL_SVG_LENGTH_RE.fullmatch(attrs.get(name, ""))\n unit = found and _PLL_SVG_UNITS.get(found.group(2).lower())\n return float(found.group(1)) * unit if unit else None\n\n w, h = length("width"), length("height")\n try:\n box = [float(n) for n in _pll_img_re.split(r"[\\s,]+", attrs.get("viewbox", "").strip())][2:]\n except ValueError:\n box = []\n if w and h:\n return (w, h)\n if len(box) == 2 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 tuple(box)\n return None\n\n\ndef _pll_picture_kind(data):\n """`(media type, (width, height))` for the picture in `data`, or None.\n\n The size may be None, for a picture whose header could not be read.\n """\n\n def number(start, end, order="little"):\n return int.from_bytes(data[start:end], order)\n\n def big(start, end):\n return number(start, end, "big")\n\n if data.startswith(b"\\x89PNG\\r\\n\\x1a\\n") and data[12:16] == b"IHDR":\n return ("image/png", (big(16, 20), big(20, 24)))\n if data.startswith(b"\\xff\\xd8"):\n return ("image/jpeg", _pll_jpeg_size(data, big))\n if data.startswith((b"GIF87a", b"GIF89a")):\n return ("image/gif", (number(6, 8), number(8, 10)))\n if data.startswith(b"RIFF") and data[8:12] == b"WEBP":\n return ("image/webp", _pll_webp_size(data, number))\n size = _pll_svg_size(data)\n return ("image/svg+xml", size) if size else None\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\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 media_type, size = _pll_picture_kind(data) or (None, None)\n if size is None:\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 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 _Picture(width, height, media_type, data, source)\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 _Picture(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# Putting pictures together\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, param):\n if isinstance(place, str) and place in allowed:\n return place\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\ndef _pll_line_up(images, across, place="center"):\n """`images` left to right (`across`) or top to bottom, lined up by `place`."""\n if across:\n w = sum(i._w for i in images)\n h = max((i._h for i in images), default=0.0)\n else:\n w = max((i._w for i in images), default=0.0)\n h = sum(i._h for i in images)\n children = []\n at = 0.0\n for image in images:\n if across:\n children.append((image, _pll_translate(at, _pll_offset(place, h, image._h)), None))\n at += image._w\n else:\n children.append((image, _pll_translate(_pll_offset(place, w, image._w), at), None))\n at += image._h\n return _Group(w, h, children)\n\n\ndef _pll_overlay(images, x_place="center", y_place="center"):\n """`images` on top of one another, the first on top, lined up by the places."""\n w = max(i._w for i in images)\n h = max(i._h for i in images)\n return _Group(w, h, [\n (i, _pll_translate(_pll_offset(x_place, w, i._w), _pll_offset(y_place, h, i._h)), None)\n for i in reversed(images)\n ])\n\n\ndef _pll_layer_xy(first, dx, dy, second, first_on_top):\n """`first` at the origin, `second` offset by (dx, dy).\n\n Negative offsets move `second` left / up, which grows the box that way:\n both move right / down by however far it grew, so the picture\'s own\n top-left stays at (0, 0).\n """\n left, top = min(0.0, dx), min(0.0, dy)\n w = max(first._w, dx + second._w) - left\n h = max(first._h, dy + second._h) - top\n under = (first, _pll_translate(-left, -top), None)\n over = (second, _pll_translate(-left + dx, -top + dy), None)\n return _Group(w, h, [over, under] if first_on_top else [under, over])\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_number(radius, "circle", "radius", least=0)\n return _Ellipse(radius * 2, radius * 2, _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_number(side, "square", "side", least=0)\n return _pll_rectangle(side, side, _pll_check_mode(mode, "square"), _pll_check_color(color, "square"))\n\n\ndef rectangle(width, height, mode, color):\n """A rectangle `width` across and `height` high."""\n width = _pll_check_number(width, "rectangle", "width", least=0)\n height = _pll_check_number(height, "rectangle", "height", least=0)\n return _pll_rectangle(\n width, height, _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_number(width, "ellipse", "width", least=0)\n height = _pll_check_number(height, "ellipse", "height", least=0)\n return _Ellipse(width, height, _pll_check_mode(mode, "ellipse"), _pll_check_color(color, "ellipse"))\n\n\ndef triangle(side, mode, color):\n """An equilateral triangle pointing up, with sides `side` long."""\n side = _pll_check_number(side, "triangle", "side", least=0)\n h = side * _math.sqrt(3) / 2.0\n points = [(side / 2.0, 0.0), (side, h), (0.0, h)]\n return _Polygon(points, _pll_check_mode(mode, "triangle"), _pll_check_color(color, "triangle"))\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_number(width, "right_triangle", "width", least=0)\n height = _pll_check_number(height, "right_triangle", "height", least=0)\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_number(side, "regular_polygon", "side", least=0)\n sides = _pll_check_number(sides, "regular_polygon", "sides", least=3, whole=True)\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 # With a *side* along the bottom, as a regular polygon is expected to\n # look. A vertex at the top gives that when the number of sides is odd -\n # a triangle points up - but when it is even puts a vertex at the bottom\n # too, so a square would be a diamond; half a step of turn fixes those.\n offset = 0.0 if sides % 2 else _math.pi / sides\n points = []\n for i in range(sides):\n # Starting at the top, going 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 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_number(side, "star", "side", least=0)\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_number(side, "star_polygon", "side", least=0)\n step = _pll_check_number(step, "star_polygon", "step", least=1, whole=True)\n # Joining every `step`-th corner makes a star only when that is less\n # than half the way round.\n count = _pll_check_number(points_count, "star_polygon", "points_count", least=2 * step + 1, whole=True)\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_number(size, "text", "size", above=0)\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 _pll_line_up(_pll_check_images(images, "beside"), True)\n\n\ndef above(*images):\n """The images one above the other, top to bottom, lined up on their centres."""\n return _pll_line_up(_pll_check_images(images, "above"), False)\n\n\ndef overlay(*images):\n """The images on top of one another, centred; the first is on top."""\n return _pll_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 _pll_overlay(_pll_check_images(images, "underlay", least=1)[::-1])\n\n\ndef beside_align(y_place, *images):\n """Like `beside`, lined up by "top", "center" or "bottom"."""\n images = _pll_check_images(images, "beside_align", 1)\n return _pll_line_up(images, True, _pll_check_place(y_place, _PLL_Y_PLACES, "beside_align", "y_place"))\n\n\ndef above_align(x_place, *images):\n """Like `above`, lined up by "left", "center" or "right"."""\n images = _pll_check_images(images, "above_align", 1)\n return _pll_line_up(images, False, _pll_check_place(x_place, _PLL_X_PLACES, "above_align", "x_place"))\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 _pll_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 _pll_overlay(_pll_check_images(images, "underlay_align", 2, least=1)[::-1], x_place, y_place)\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 _pll_layer_xy(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 _pll_layer_xy(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 w, h = scene._w, scene._h\n region = _pll_box_region(w, h)\n at = _pll_translate(x - image._w / 2.0, y - image._h / 2.0)\n edges = _pll_rectangle(w, h, "solid", _PLL_TRANSPARENT)\n return _Group(w, h, [(scene, None, region), (image, at, region), (edges, None, None)])\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_number(width, "crop", "width", least=0)\n height = _pll_check_number(height, "crop", "height", least=0)\n _pll_check_image(image, "crop", 4)\n edges = _pll_rectangle(width, height, "solid", _PLL_TRANSPARENT)\n return _Group(width, height, [\n (image, _pll_translate(-x, -y), _pll_box_region(width, height)),\n (edges, None, None),\n ])\n\n\ndef frame(image):\n """`image` with a thin outline around it, to show its bounding box."""\n image = _pll_check_image(image, "frame", 0)\n outline = _pll_rectangle(image._w, image._h, "outline", _PLL_BLACK)\n return _Group(image._w, image._h, [(image, None, None), (outline, None, None)])\n\n\ndef empty_scene(width, height):\n """A blank white scene with an outline, to use with `place_image`."""\n width = _pll_check_number(width, "empty_scene", "width", least=0)\n height = _pll_check_number(height, "empty_scene", "height", least=0)\n return frame(_pll_rectangle(width, height, "solid", _PLL_WHITE))\n\n\ndef rotate(angle, image):\n """`image` turned `angle` degrees counter-clockwise, as HtDP turns it.\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 _pll_check_order(angle, image, "rotate", "angle")\n angle = _pll_check_number(angle, "rotate", "angle") % 360.0\n image = _pll_check_image(image, "rotate", 1)\n if 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(angle // 90)]\n else:\n rad = _math.radians(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 left = min((b[0] for b in boxes), default=0.0)\n top = min((b[1] for b in boxes), default=0.0)\n w = max((b[2] for b in boxes), default=0.0) - left\n h = max((b[3] for b in boxes), default=0.0) - top\n return _Group(w, h, [(image, _pll_compose(_pll_translate(-left, -top), turn), None)])\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_number(factor, "scale", "factor", above=0)\n image = _pll_check_image(image, "scale", 1)\n return _Group(image._w * factor, image._h * factor, [(image, (factor, 0.0, 0.0, factor, 0.0, 0.0), None)])\n\n\ndef flip_horizontal(image):\n """`image` mirrored left to right."""\n image = _pll_check_image(image, "flip_horizontal", 0)\n return _Group(image._w, image._h, [(image, (-1.0, 0.0, 0.0, 1.0, image._w, 0.0), None)])\n\n\ndef flip_vertical(image):\n """`image` mirrored top to bottom."""\n image = _pll_check_image(image, "flip_vertical", 0)\n return _Group(image._w, image._h, [(image, (1.0, 0.0, 0.0, -1.0, 0.0, image._h), None)])\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 = _pll_rectangle(0.0, 0.0, "solid", _PLL_TRANSPARENT)\n\n\n#: The names the install step copies into the student\'s globals.\nPLL_IMAGE_EXPORTS = """\n Image circle square rectangle ellipse triangle right_triangle\n regular_polygon star star_polygon line text beside above overlay underlay\n beside_align above_align overlay_align underlay_align overlay_xy\n underlay_xy place_image crop frame empty_scene rotate scale\n flip_horizontal flip_vertical image_width image_height empty_image\n load_image\n""".split()\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] = _pll_value.__code__.co_varnames[: _pll_value.__code__.co_argcount]\ndel _pll_exported, _pll_value\n';
231
231
 
232
232
  // src/python/libraries/table.py
233
- var table_default = '# PLL table library.\n#\n# A small, immutable table: named columns, and rows of values in them. It is\n# stored column by column (a dict of name -> list of values), so changing or\n# adding up one column touches only that column. Every operation returns a\n# new `Table`; nothing mutates. Charts are images, drawn as SVG at the end of\n# this file.\n\nimport csv as _csv\nimport io as _io\nimport math as _math\nimport numbers as _pll_tbl_numbers\nimport statistics as _pll_tbl_statistics\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_table_differences`), 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\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_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\ndef _pll_bools_are_numbers():\n """Whether True and False count as 1 and 0 for the student\'s code: as in\n Python, except at the teaching levels, where neither is a number."""\n return _pll_student_level() not in _PLL_TEACHING_LEVELS\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`. Too far for a spelling match, and still almost\n # 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_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_literal(name),\n ", ".join(columns),\n " Did you mean %s?" % _pll_literal(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, and not the result of calling one:\n `transform_column("servings", int())`."""\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 return fn\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 with no code\n of its own - `int`, `float` - is reworded: an error in a function of the\n student\'s goes on as it was raised, with its own line, to be explained\n like any other.\n """\n try:\n return fn(value)\n except (ValueError, TypeError) as exc:\n if (\n type(exc) not in (ValueError, TypeError)\n or hasattr(fn, "__code__")\n or hasattr(getattr(fn, "__init__", None), "__code__")\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_literal(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 A row says 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_literal(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)` or `load_table("file.csv")`. A\n table never changes: every method that changes something gives back a\n 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 _pll_check_column_name(c, "table")\n _pll_check_repeated(cols, "table")\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 self._columns = cols\n self._data = data\n self._length = n\n\n @classmethod\n def _from_columns(cls, columns, data, length):\n """A table from columns already checked."""\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 = dict(self._data)\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_literal(name), _pll_literal(name))\n )\n if callable(values_or_fn):\n new_values = [values_or_fn(row) for row in self.rows()]\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_literal(name),\n self._length,\n )\n )\n new_data = dict(self._data)\n new_data[name] = new_values\n return Table._from_columns(self._columns + [name], 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 return Table._from_columns(self._columns, new_data, self._length + 1)\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_literal(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(map(_pll_literal, repeated))\n )\n return Table._from_columns(names, self._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 numbers = _pll_bools_are_numbers()\n # Sort indices to keep all columns in lockstep.\n order = sorted(\n range(self._length),\n key=lambda i: _sort_key(values[i], numbers),\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_check_number(n, "head", "n", least=0, whole=True)\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_check_number(n, "tail", "n", least=0, whole=True)\n return self._slice(max(0, self._length - n), 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 return self._extreme(name, "min", "no smallest value", min)\n\n def max(self, name):\n """Maximum value in `name`."""\n return self._extreme(name, "max", "no largest value", max)\n\n def _extreme(self, name, op, none, pick):\n self._require_column(name)\n if self._length == 0:\n raise ValueError(self._empty(op, none))\n self._refuse_numbers_as_text(name, op, "compares")\n numbers = _pll_bools_are_numbers()\n return pick(self._data[name], key=lambda v: _sort_key(v, numbers))\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 values = self._chart_values(y, "bar_chart", swap_with=x)\n return _bar_chart([_format_cell(v) for v in self._data[x]], 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._xy("scatter_chart", x, y, title)\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._xy("scatter_plot", x, y, title)\n\n def line_chart(self, x, y, title=None):\n """Line chart of x vs y (both numeric, sorted by x)."""\n return self._xy("line_chart", x, y, title, mode="line")\n\n def labeled_scatter_plot(self, labels, x, y, title=None):\n """Scatter plot with the points coloured and keyed by `labels`."""\n return self._xy("labeled_scatter_plot", x, y, title, labels)\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 return self._xy("lr_plot", x, y, title, fit=True)\n\n def labeled_lr_plot(self, labels, x, y, title=None):\n """`lr_plot`, with the points coloured and keyed by `labels`."""\n return self._xy("labeled_lr_plot", x, y, title, labels, fit=True)\n\n def _xy(self, who, x, y, title, labels=None, fit=False, mode="scatter"):\n """`y` against `x`: the points, coloured by `labels`, with the line\n of best fit through them when asked."""\n if labels is not None:\n self._require_column(labels)\n xs = self._chart_values(x, who)\n ys = self._chart_values(y, who)\n lines = ()\n if fit:\n slope, intercept, r_squared = _pll_linear_fit(xs, ys)\n lines = [([min(xs), max(xs)], [slope * min(xs) + intercept, slope * max(xs) + intercept])]\n if title is None:\n title = _fit_title(slope, intercept, r_squared)\n if mode == "line":\n # Sorted by x, so the line runs left to right.\n pairs = sorted(zip(xs, ys), key=lambda p: p[0])\n xs, ys = [p[0] for p in pairs], [p[1] for p in pairs]\n names = [_format_cell(v) for v in self._data[labels]] if labels is not None else None\n return _xy_chart(xs, ys, x, y, title, mode, names, lines)\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 usually what the data calls for ("group ages by\n 5"): the buckets then start at a multiple of the width.\n """\n values = self._chart_values(name, "histogram")\n if bin_width is None:\n bins = _pll_check_number(bins, "histogram", "bins", least=1, whole=True)\n else:\n bin_width = _pll_check_number(bin_width, "histogram", "bin_width", above=0)\n return _histogram(values, bins, name, title, bin_width)\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._chart_values(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_literal(values), _pll_number(amount), _pll_ordinal(index))\n )\n return _pie_chart([_format_cell(v) for v in self._data[labels]], 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 return _dot_plot(self._chart_values(name, "dot_plot"), 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._chart_values(name, "labeled_dot_plot")\n return _dot_plot(values, [_format_cell(v) for v in self._data[labels]], 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 _bar_chart(keys, [float(counts[k][1]) for k in keys], name, "count", title)\n\n def box_plot(self, name, title=None):\n """Box and whisker plot of `name`: quartiles, range and outliers."""\n return _box_plot(self._chart_values(name, "box_plot"), name, title)\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_real(v) for v in cells))\n csv_text, csv_rows = self._csv_text(_PLL_CSV_LIMIT)\n return {\n "kind": "table",\n "columns": list(self._columns),\n "rows": rows,\n "rowCount": n,\n "shownCount": shown,\n "truncated": n > shown,\n "numeric": numeric,\n "csv": csv_text,\n "csvRows": 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 """This table as a pandas DataFrame. A program that calls this has\n pandas loaded for it before it runs."""\n import pandas as _pd\n\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_literal(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_literal(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_literal(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 two of them are\n in a different order as text than as numbers, the column was almost\n certainly never converted.\n """\n texts = [v for v in self._data[name] if v is not None and v != ""]\n if not all(isinstance(t, str) for t in texts):\n return\n try:\n pairs = sorted((t, float(t)) for t in texts)\n except ValueError:\n return\n for (first, a), (second, b) in zip(pairs, pairs[1:]):\n if a > b:\n raise TypeError(\n "%s %s column %s as text, and as text %s comes before %s: the "\n "values were read from a CSV and never converted to numbers. "\n "Convert the column first: transform_column(%s, %s)."\n % (\n op,\n does,\n _pll_literal(name),\n _pll_literal(first),\n _pll_literal(second),\n _pll_literal(name),\n "int" if all(n.is_integer() for _, n in pairs) 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. True and False are refused at the\n teaching levels, where neither is a number, and are 1 and 0 at the\n others, as in Python.\n """\n self._require_column(name)\n strict = not _pll_bools_are_numbers()\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%s."\n % (\n op,\n _pll_literal(name),\n v,\n _pll_ordinal(index),\n "; to count the rows where it is True, filter on it and take the length"\n if counting\n else "",\n )\n )\n out.append(int(v) if counting else float(v))\n continue\n if _pll_real(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_literal(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 _chart_values(self, name, op, swap_with=None):\n """A column of numbers to draw, from a table with rows to draw."""\n values = self._numeric_column(name, op, swap_with)\n if not values:\n raise ValueError(self._empty(op, "nothing to draw"))\n return values\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 return bool(values) and 2 * sum(map(_pll_could_be_number, values)) >= 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_literal(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, op, op, _pll_literal(name), _pll_literal(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_literal(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_check_repeated(names, where):\n """Every column name once: a table, or a CSV\'s first row."""\n for i, name in enumerate(names):\n if name in names[:i]:\n raise ValueError(\n "%s has two columns called %s. Column names have to be "\n "different, so give one of them another name." % (where, _pll_literal(name))\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 for key in row:\n if key not in cols:\n raise ValueError("the %s row: %s" % (_pll_ordinal(index), _pll_no_column(key, cols)))\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, its columns in\n the dict\'s order."""\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 columns = {}\n for c, values in data.items():\n _pll_check_column_name(c, "table_from_columns")\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_literal(c), _pll_describe(values))\n )\n columns[c] = list(values)\n first = next(iter(columns))\n if len(columns[c]) != len(columns[first]):\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_literal(first),\n len(columns[first]),\n "" if len(columns[first]) == 1 else "s",\n _pll_literal(c),\n len(columns[c]),\n )\n )\n return Table(list(columns), zip(*columns.values()))\n\n\ndef _pll_refuse_html(text, source):\n """Stop early when the address gave a web page rather than a CSV, as a\n GitHub file\'s own address does: parsed as CSV, the page\'s errors are\n about lines nobody wrote."""\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**,\n including ones that look like numbers; an empty cell is the empty\n string. Convert a column when you want to chart or average it:\n\n cars = load_table("cars.csv").transform_column("mpg", float)\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 _pll_check_repeated(header, _pll_literal(source))\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_literal(source), line)\n ) from None\n raise ValueError(\n "Could not read %s as a CSV: line %d: %s" % (_pll_literal(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: there is no chart to borrow a window from, and\n 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 x_min = _pll_check_number(x_min, "function_plot", "x_min")\n x_max = _pll_check_number(x_max, "function_plot", "x_max")\n if x_max <= x_min:\n raise ValueError("function_plot needs x_max to be greater than x_min")\n steps = _pll_check_number(steps, "function_plot", "steps", least=2, whole=True)\n xs = []\n ys = []\n starts = []\n gap = True\n failed = None\n numbers = _pll_bools_are_numbers()\n for i in range(steps + 1):\n x = x_min + (x_max - x_min) * i / steps\n try:\n got = f(x)\n value = float(got) if _pll_real(got) or isinstance(got, bool) else None\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 got = value = None\n if isinstance(got, bool) and not numbers:\n raise TypeError(\n "function_plot needs your function to give back numbers, but for x = %s it gave "\n "back %s. True and False are not numbers." % (_pll_number(x), got)\n )\n if value is None or not _math.isfinite(value):\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 _xy_chart(xs, ys, "x", "y", title, "line", markers=False, starts=starts)\n\n\n# -----------------------------------------------------------------------------\n# Numeric helpers\n# -----------------------------------------------------------------------------\n\ndef _pll_could_be_number(value):\n """A number, or text that reads as one: `"29"` from a CSV is exactly\n the case where suggesting a conversion is right."""\n try:\n return _math.isfinite(float(value.strip() if isinstance(value, str) else value))\n except (TypeError, ValueError, OverflowError):\n return False\n\n\ndef _sort_key(value, bools_are_numbers=True):\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. True and False\n are 1 and 0 - or, where they are not numbers, their own kind, after\n the numbers.\n """\n if value is None:\n return (0, 0)\n if isinstance(value, bool):\n return (1 if bools_are_numbers else 1.75, int(value))\n if _pll_real(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\ndef _pll_linear_fit(xs, ys):\n """Least squares: (slope, intercept, r_squared)."""\n if len(xs) < 2:\n raise ValueError("a line of best fit needs at least two rows")\n if len(set(xs)) == 1:\n raise ValueError("a line of best fit needs the x values to vary; they are all %g" % xs[0])\n slope, intercept = _pll_tbl_statistics.linear_regression(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 len(set(ys)) == 1 else _pll_tbl_statistics.correlation(xs, ys) ** 2\n return slope, intercept, r_squared\n\n\ndef _fit_title(slope, intercept, r_squared):\n return "y=%.3fx %s %.3f; r-sq: %.3f" % (\n slope, "+" if intercept >= 0 else "-", abs(intercept), r_squared,\n )\n\n\n# -----------------------------------------------------------------------------\n# Charts\n# -----------------------------------------------------------------------------\n#\n# A chart is SVG made into an image (`_PllChart`), so it is displayed,\n# `beside`d and annotated `-> Image` like any picture. Each is a white box\n# with a title (`_chart`), and most have a plotting area (`_plot_box`) with\n# an axis (`_axis`) along its bottom and its left, each mapping values onto\n# pixels with a `_Scale`.\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#: Series colours, 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\nclass _PllChart(_Picture):\n """A chart: an image, which displays and combines like any other."""\n\n def __init__(self, markup, width, height):\n self._markup = markup\n _Picture.__init__(self, width, height, "image/svg+xml", markup.encode(), "a chart")\n\n def to_svg(self):\n return self._markup\n\n def __repr__(self):\n return "<Chart %dx%d>" % (_pll_px(self._w), _pll_px(self._h))\n\n\ndef _plot_box(height=_CHART_H):\n """The plotting area inside a chart `height` high: (x, y, w, h)."""\n return (\n _CHART_MARGIN_L,\n _CHART_MARGIN_T,\n _CHART_W - _CHART_MARGIN_L - _CHART_MARGIN_R,\n height - _CHART_MARGIN_T - _CHART_MARGIN_B,\n )\n\n\ndef _chart(title, body, height=_CHART_H, x_label=None, y_label=None):\n """The chart: a white box with its title, and its axes\' names if it has axes."""\n px, py, pw, ph = _plot_box(height)\n words = ""\n if title:\n words += (\n \'<text x="%d" y="%d" text-anchor="middle" font-size="13" font-weight="600">%s</text>\'\n % (_CHART_W / 2, _CHART_MARGIN_T - 12, _pll_xml_escape(title))\n )\n if x_label is not None:\n words += (\n \'<text x="%d" y="%d" text-anchor="middle" font-size="11" font-style="italic">%s</text>\'\n \'<text x="%d" y="%d" text-anchor="middle" font-size="11" \'\n \'font-style="italic" transform="rotate(-90 %d %d)">%s</text>\'\n ) % (\n px + pw / 2, height - 8, _pll_xml_escape(x_label),\n 16, py + ph / 2, 16, py + ph / 2, _pll_xml_escape(y_label),\n )\n markup = (\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 ) % (_CHART_W, height, _CHART_W, height, _CHART_W, height, words, "".join(body))\n return _PllChart(markup, _CHART_W, height)\n\n\ndef _ticks(lo, hi, target):\n """About `target` round-numbered ticks covering `lo` to `hi`, each 1, 2\n or 5 times a power of ten apart."""\n if lo == hi:\n # One value: padded, so there is still an axis to draw.\n lo, hi = (-1.0, 1.0) if lo == 0 else (lo - abs(lo) * 0.5, hi + abs(hi) * 0.5)\n raw = (hi - lo) / target\n mag = 10.0 ** _math.floor(_math.log10(raw))\n step = next(nice for top, nice in ((1.5, 1.0), (3.5, 2.0), (7.5, 5.0), (_math.inf, 10.0)) if raw / mag < top) * mag\n first = _math.floor(lo / step)\n return [(first + i) * step for i in range(_math.ceil(hi / step) - first + 1)]\n\n\nclass _Scale:\n """Values from `lo` to `hi` onto pixels: from `start`, `length` long\n (negative going up the page), with round-numbered ticks along it."""\n\n def __init__(self, lo, hi, target, start, length):\n self.ticks = _ticks(lo, hi, target)\n self.lo, self.hi = self.ticks[0], self.ticks[-1]\n self.start, self.length = start, length\n\n def __call__(self, value):\n return self.start + (value - self.lo) / (self.hi - self.lo) * self.length\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 _axis(box, across, marks, lines=True):\n """An axis along the bottom of the plot (`across`) or up its left: each\n of `marks`, a (pixel, label) pair, labelled, and with a light line across\n the plot when `lines`."""\n px, py, pw, ph = box\n grid = []\n words = []\n for at, label in marks:\n if lines:\n grid.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>\'\n % ((at, py, at, py + ph) if across else (px, at, px + pw, at))\n )\n words.append(\n \'<text x="%g" y="%g" font-size="10" text-anchor="middle" fill="#444">%s</text>\'\n % (at, py + ph + 14, _pll_xml_escape(label))\n if across\n else \'<text x="%g" y="%g" font-size="10" text-anchor="end" fill="#444" \'\n \'dominant-baseline="middle">%s</text>\' % (px - 6, at, _pll_xml_escape(label))\n )\n return \'<g stroke="#bbb" stroke-width="1" fill="none">%s<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/></g>%s\' % (\n "".join(grid),\n *((px, py + ph, px + pw, py + ph) if across else (px, py, px, py + ph)),\n "".join(words),\n )\n\n\ndef _numbered(scale):\n """A scale\'s ticks as an axis\'s marks."""\n return [(scale(t), _format_tick(t)) for t in scale.ticks]\n\n\ndef _label_colours(labels):\n """A colour for each label, in the order they first appear, so the key\n reads in the same order as the table."""\n colors = {}\n for label in labels:\n if label not in colors:\n colors[label] = _PALETTE[len(colors) % len(_PALETTE)]\n return colors\n\n\ndef _legend(entries, y, x=None):\n """A colour key, on a white panel so it stays readable over the data.\n\n At `x`, or else at the plot\'s right - where above eight entries it is\n left out: a wall of text, while the colours still tell the groups apart.\n """\n width = 24.0 + max(len(str(label)) for _, label in entries) * 6.2\n if x is None:\n if len(entries) > 8:\n return ""\n px, _, pw, _ = _plot_box()\n x = px + pw - width - 4\n rows = [\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="white" \'\n \'fill-opacity="0.85" stroke="#ddd"/>\' % (x, y, width, 6 + len(entries) * 14)\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"/>\' % (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 _bar_chart(labels, values, x_label, y_label, title):\n box = px, py, pw, ph = _plot_box()\n lo = min(0.0, min(values))\n hi = max(values)\n y = _Scale(lo, hi if hi != lo else lo + 1.0, 5, py + ph, -ph)\n slot = pw / len(values)\n centres = [px + slot * (i + 0.5) for i in range(len(values))]\n body = [_axis(box, False, _numbered(y)), _axis(box, True, zip(centres, labels), lines=False)]\n bar_w = slot * 0.7\n for cx, v in zip(centres, values):\n y0, yv = y(0), y(v)\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" />\'\n % (cx - bar_w / 2, min(y0, yv), bar_w, abs(y0 - yv))\n )\n return _chart(title, body, x_label=x_label, y_label=y_label)\n\n\ndef _xy_chart(xs, ys, x_label, y_label, title, mode, labels=None, lines=(), markers=True, starts=(0,)):\n """Points, joined by a line or coloured by label, and any fitted lines.\n\n `starts` are the points a line starts afresh at, after a gap.\n """\n box = px, py, pw, ph = _plot_box()\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 sx = _Scale(min(all_x), max(all_x), 6, px, pw)\n sy = _Scale(min(all_y), max(all_y), 5, py + ph, -ph)\n body = [_axis(box, False, _numbered(sy)), _axis(box, True, _numbered(sx))]\n points = [(sx(x), sy(y)) for x, y in zip(xs, ys)]\n if mode == "line":\n lifted = set(starts) | {0}\n body.append(\n \'<path d="%s" fill="none" stroke="#4f8cff" stroke-width="2" \'\n \'stroke-linejoin="round" stroke-linecap="round"/>\'\n % " ".join("%s %g %g" % ("M" if i in lifted else "L", a, b) for i, (a, b) in enumerate(points))\n )\n if markers:\n body.extend(\'<circle cx="%g" cy="%g" r="2.5" fill="#4f8cff"/>\' % p for p in points)\n elif labels is None:\n body.extend(\'<circle cx="%g" cy="%g" r="3" fill="#4f8cff" opacity="0.7"/>\' % p for p in points)\n else:\n colors = _label_colours(labels)\n for (a, b), label in zip(points, labels):\n body.append(\'<circle cx="%g" cy="%g" r="3.5" fill="%s" opacity="0.85"/>\' % (a, b, colors[label]))\n body.append(_legend([(c, label) for label, c in colors.items()], py + 4))\n for line_xs, line_ys in lines:\n body.append(\n \'<path d="%s" fill="none" stroke="#eb5757" stroke-width="2"/>\'\n % ("M " + " L ".join("%g %g" % (sx(x), sy(y)) for x, y in zip(line_xs, line_ys)))\n )\n return _chart(title, body, x_label=x_label, y_label=y_label)\n\n\ndef _pie_chart(labels, values, title, column):\n """Slices plus a key, with each slice\'s share of the total."""\n total = sum(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_literal(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 % len(_PALETTE)]\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(_legend(legend, _CHART_MARGIN_T + 4, _CHART_W * 0.62))\n return _chart(title, body)\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 _box_plot(values, name, title):\n """A box from the quartiles, whiskers to 1.5*IQR, outliers as points.\n\n Quartiles as `statistics.quantiles(..., method="inclusive")` and numpy\'s\n default percentile give them, so a student can check the box by hand.\n """\n box = px, py, pw, ph = _plot_box(_BOX_PLOT_H)\n q1, median, q3 = _pll_tbl_statistics.quantiles(values, n=4, method="inclusive")\n iqr = q3 - q1\n inside = [v for v in values if q1 - 1.5 * iqr <= v <= q3 + 1.5 * iqr] or values\n low, high = min(inside), max(inside)\n x = _Scale(min(values), max(values), 6, px, pw)\n mid = py + ph * 0.58\n half = ph * 0.26\n body = [\n _axis(box, True, _numbered(x)),\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#444"/>\' % (x(low), mid, 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 % (x(v), mid - half * 0.5, 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 % (x(q1), mid - half, max(x(q3) - 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 % (x(median), mid - half, x(median), mid + half)\n )\n for v in values:\n if v < low or v > high:\n body.append(\'<circle cx="%g" cy="%g" r="3" fill="none" stroke="#eb5757"/>\' % (x(v), mid))\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 _chart(title, body, _BOX_PLOT_H, name, "")\n\n\n_DOT_RADIUS = 5.0\n\n\ndef _dot_plot(values, labels, name, title):\n """One dot per row, stacked where rows share a value.\n\n Sized to the tallest stack, and with no y-axis: stacking says "two rows\n had this value", which is not a quantity to put a scale against.\n """\n tally = {}\n for v in values:\n tally[v] = tally.get(v, 0) + 1\n stack_h = max(tally.values()) * (_DOT_RADIUS * 2 + 1) + _DOT_RADIUS\n colors = _label_colours(labels) if labels is not None else {}\n # The key sits inside the plot area, so the strip has to be tall enough\n # to hold it, or a tall stack at the right-hand end draws through it.\n legend_h = (6 + len(colors) * 14 + 8) if 0 < len(colors) <= 8 else 0\n chart_h = _CHART_MARGIN_T + max(64.0, stack_h + 10, legend_h + stack_h) + _CHART_MARGIN_B\n box = px, py, pw, ph = _plot_box(chart_h)\n x = _Scale(min(values), max(values), 6, px, pw)\n body = [_axis(box, True, _numbered(x))]\n seen = {}\n for i, v in enumerate(values):\n level = seen.get(v, 0)\n seen[v] = level + 1\n body.append(\n \'<circle cx="%g" cy="%g" r="%g" fill="%s" opacity="0.85"/>\'\n % (\n x(v),\n py + ph - _DOT_RADIUS - level * (_DOT_RADIUS * 2 + 1),\n _DOT_RADIUS,\n colors[labels[i]] if labels is not None else "#4f8cff",\n )\n )\n if colors:\n body.append(_legend([(c, label) for label, c in colors.items()], py + 2))\n return _chart(title, body, chart_h, name, "")\n\n\ndef _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 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 if bins > 1000:\n raise ValueError(\n "histogram would draw %d bars, and at most 1000 fit: %s."\n % (bins, "use a wider bin_width" if bin_width is not None else "ask for fewer bins")\n )\n counts = [0] * bins\n for v in values:\n counts[min(int((v - lo) / width), bins - 1)] += 1\n box = px, py, pw, ph = _plot_box()\n y = _Scale(0, max(counts), 5, py + ph, -ph)\n x = _Scale(lo, hi, 6, px, pw)\n body = [_axis(box, False, _numbered(y)), _axis(box, True, _numbered(x))]\n for i, c in enumerate(counts):\n left, right = x(lo + i * width), x(lo + i * width + width)\n # Inset slightly so bars don\'t touch.\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" opacity="0.85"/>\'\n % (left + 0.5, y(c), max(1.0, right - left - 1), y.start - y(c))\n )\n return _chart(title, body, x_label=name, y_label="count")\n\n\n#: The names the install step copies into the student\'s globals.\nPLL_TABLE_EXPORTS = "Table Row table table_from_columns load_table function_plot".split()\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';
233
+ var table_default = '# PLL table library.\n#\n# A small, immutable table: named columns, and rows of values in them. It is\n# stored column by column (a dict of name -> list of values), so changing or\n# adding up one column touches only that column. Every operation returns a\n# new `Table`; nothing mutates. Charts are images, drawn as SVG at the end of\n# this file.\n\nimport csv as _csv\nimport io as _io\nimport math as _math\nimport numbers as _pll_tbl_numbers\nimport statistics as _pll_tbl_statistics\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_table_differences`), 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\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_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\ndef _pll_bools_are_numbers():\n """Whether True and False count as 1 and 0 for the student\'s code: as in\n Python, except at the teaching levels, where neither is a number."""\n return _pll_student_level() not in _PLL_TEACHING_LEVELS\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`. Too far for a spelling match, and still almost\n # 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_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_literal(name),\n ", ".join(columns),\n " Did you mean %s?" % _pll_literal(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, and not the result of calling one:\n `transform_column("servings", int())`."""\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 return fn\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 with no code\n of its own - `int`, `float` - is reworded: an error in a function of the\n student\'s goes on as it was raised, with its own line, to be explained\n like any other.\n """\n try:\n return fn(value)\n except (ValueError, TypeError) as exc:\n if (\n type(exc) not in (ValueError, TypeError)\n or hasattr(fn, "__code__")\n or hasattr(getattr(fn, "__init__", None), "__code__")\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_literal(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 A row says 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_literal(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)` or `load_table("file.csv")`. A\n table never changes: every method that changes something gives back a\n 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 _pll_check_column_name(c, "table")\n _pll_check_repeated(cols, "table")\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 self._columns = cols\n self._data = data\n self._length = n\n\n @classmethod\n def _from_columns(cls, columns, data, length):\n """A table from columns already checked."""\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 = dict(self._data)\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_literal(name), _pll_literal(name))\n )\n if callable(values_or_fn):\n new_values = [values_or_fn(row) for row in self.rows()]\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_literal(name),\n self._length,\n )\n )\n new_data = dict(self._data)\n new_data[name] = new_values\n return Table._from_columns(self._columns + [name], 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 return Table._from_columns(self._columns, new_data, self._length + 1)\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_literal(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(map(_pll_literal, repeated))\n )\n return Table._from_columns(names, self._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 numbers = _pll_bools_are_numbers()\n # Sort indices to keep all columns in lockstep.\n order = sorted(\n range(self._length),\n key=lambda i: _sort_key(values[i], numbers),\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_check_number(n, "head", "n", least=0, whole=True)\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_check_number(n, "tail", "n", least=0, whole=True)\n return self._slice(max(0, self._length - n), 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 return self._extreme(name, "min", "no smallest value", min)\n\n def max(self, name):\n """Maximum value in `name`."""\n return self._extreme(name, "max", "no largest value", max)\n\n def _extreme(self, name, op, none, pick):\n self._require_column(name)\n if self._length == 0:\n raise ValueError(self._empty(op, none))\n self._refuse_numbers_as_text(name, op, "compares")\n numbers = _pll_bools_are_numbers()\n return pick(self._data[name], key=lambda v: _sort_key(v, numbers))\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 values = self._chart_values(y, "bar_chart", swap_with=x)\n return _bar_chart([_format_cell(v) for v in self._data[x]], 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._xy("scatter_chart", x, y, title)\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._xy("scatter_plot", x, y, title)\n\n def line_chart(self, x, y, title=None):\n """Line chart of x vs y (both numeric, sorted by x)."""\n return self._xy("line_chart", x, y, title, mode="line")\n\n def labeled_scatter_plot(self, labels, x, y, title=None):\n """Scatter plot with the points coloured and keyed by `labels`."""\n return self._xy("labeled_scatter_plot", x, y, title, labels)\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 return self._xy("lr_plot", x, y, title, fit=True)\n\n def labeled_lr_plot(self, labels, x, y, title=None):\n """`lr_plot`, with the points coloured and keyed by `labels`."""\n return self._xy("labeled_lr_plot", x, y, title, labels, fit=True)\n\n def _xy(self, who, x, y, title, labels=None, fit=False, mode="scatter"):\n """`y` against `x`: the points, coloured by `labels`, with the line\n of best fit through them when asked."""\n if labels is not None:\n self._require_column(labels)\n xs = self._chart_values(x, who)\n ys = self._chart_values(y, who)\n lines = ()\n if fit:\n slope, intercept, r_squared = _pll_linear_fit(xs, ys)\n lines = [([min(xs), max(xs)], [slope * min(xs) + intercept, slope * max(xs) + intercept])]\n if title is None:\n title = _fit_title(slope, intercept, r_squared)\n if mode == "line":\n # Sorted by x, so the line runs left to right.\n pairs = sorted(zip(xs, ys), key=lambda p: p[0])\n xs, ys = [p[0] for p in pairs], [p[1] for p in pairs]\n names = [_format_cell(v) for v in self._data[labels]] if labels is not None else None\n return _xy_chart(xs, ys, x, y, title, mode, names, lines)\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 usually what the data calls for ("group ages by\n 5"): the buckets then start at a multiple of the width.\n """\n values = self._chart_values(name, "histogram")\n if bin_width is None:\n bins = _pll_check_number(bins, "histogram", "bins", least=1, whole=True)\n else:\n bin_width = _pll_check_number(bin_width, "histogram", "bin_width", above=0)\n return _histogram(values, bins, name, title, bin_width)\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._chart_values(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_literal(values), _pll_number(amount), _pll_ordinal(index))\n )\n return _pie_chart([_format_cell(v) for v in self._data[labels]], 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 return _dot_plot(self._chart_values(name, "dot_plot"), 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._chart_values(name, "labeled_dot_plot")\n return _dot_plot(values, [_format_cell(v) for v in self._data[labels]], 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 _bar_chart(keys, [float(counts[k][1]) for k in keys], name, "count", title)\n\n def box_plot(self, name, title=None):\n """Box and whisker plot of `name`: quartiles, range and outliers."""\n return _box_plot(self._chart_values(name, "box_plot"), name, title)\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_real(v) for v in cells))\n csv_text, csv_rows = self._csv_text(_PLL_CSV_LIMIT)\n return {\n "kind": "table",\n "columns": list(self._columns),\n "rows": rows,\n "rowCount": n,\n "shownCount": shown,\n "truncated": n > shown,\n "numeric": numeric,\n "csv": csv_text,\n "csvRows": 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 """This table as a pandas DataFrame. A program that calls this has\n pandas loaded for it before it runs."""\n import pandas as _pd\n\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_literal(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_literal(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_literal(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 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 two of them are\n in a different order as text than as numbers, the column was almost\n certainly never converted.\n """\n texts = [v for v in self._data[name] if v is not None and v != ""]\n if not all(isinstance(t, str) for t in texts):\n return\n try:\n pairs = sorted((t, float(t)) for t in texts)\n except ValueError:\n return\n for (first, a), (second, b) in zip(pairs, pairs[1:]):\n if a > b:\n raise TypeError(\n "%s %s column %s as text, and as text %s comes before %s: the "\n "column holds numbers written as text, like those in quotes or "\n "read from a CSV. Convert the column first: transform_column(%s, %s)."\n % (\n op,\n does,\n _pll_literal(name),\n _pll_literal(first),\n _pll_literal(second),\n _pll_literal(name),\n "int" if all(n.is_integer() for _, n in pairs) 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. True and False are refused at the\n teaching levels, where neither is a number, and are 1 and 0 at the\n others, as in Python.\n """\n self._require_column(name)\n strict = not _pll_bools_are_numbers()\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%s."\n % (\n op,\n _pll_literal(name),\n v,\n _pll_ordinal(index),\n "; to count the rows where it is True, filter on it and take the length"\n if counting\n else "",\n )\n )\n out.append(int(v) if counting else float(v))\n continue\n if _pll_real(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_literal(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 _chart_values(self, name, op, swap_with=None):\n """A column of numbers to draw, from a table with rows to draw."""\n values = self._numeric_column(name, op, swap_with)\n if not values:\n raise ValueError(self._empty(op, "nothing to draw"))\n return values\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 return bool(values) and 2 * sum(map(_pll_could_be_number, values)) >= 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_literal(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, op, op, _pll_literal(name), _pll_literal(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. The column holds numbers written as text, like those in "\n "quotes or read from a CSV. Convert the column first: "\n "transform_column(%s, float)."\n % (base, _pll_literal(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_check_repeated(names, where):\n """Every column name once: a table, or a CSV\'s first row."""\n for i, name in enumerate(names):\n if name in names[:i]:\n raise ValueError(\n "%s has two columns called %s. Column names have to be "\n "different, so give one of them another name." % (where, _pll_literal(name))\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 for key in row:\n if key not in cols:\n raise ValueError("the %s row: %s" % (_pll_ordinal(index), _pll_no_column(key, cols)))\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, its columns in\n the dict\'s order."""\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 columns = {}\n for c, values in data.items():\n _pll_check_column_name(c, "table_from_columns")\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_literal(c), _pll_describe(values))\n )\n columns[c] = list(values)\n first = next(iter(columns))\n if len(columns[c]) != len(columns[first]):\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_literal(first),\n len(columns[first]),\n "" if len(columns[first]) == 1 else "s",\n _pll_literal(c),\n len(columns[c]),\n )\n )\n return Table(list(columns), zip(*columns.values()))\n\n\ndef _pll_refuse_html(text, source):\n """Stop early when the address gave a web page rather than a CSV, as a\n GitHub file\'s own address does: parsed as CSV, the page\'s errors are\n about lines nobody wrote."""\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**,\n including ones that look like numbers; an empty cell is the empty\n string. Convert a column when you want to chart or average it:\n\n cars = load_table("cars.csv").transform_column("mpg", float)\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 _pll_check_repeated(header, _pll_literal(source))\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_literal(source), line)\n ) from None\n raise ValueError(\n "Could not read %s as a CSV: line %d: %s" % (_pll_literal(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: there is no chart to borrow a window from, and\n 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 x_min = _pll_check_number(x_min, "function_plot", "x_min")\n x_max = _pll_check_number(x_max, "function_plot", "x_max")\n if x_max <= x_min:\n raise ValueError("function_plot needs x_max to be greater than x_min")\n steps = _pll_check_number(steps, "function_plot", "steps", least=2, whole=True)\n xs = []\n ys = []\n starts = []\n gap = True\n failed = None\n numbers = _pll_bools_are_numbers()\n for i in range(steps + 1):\n x = x_min + (x_max - x_min) * i / steps\n try:\n got = f(x)\n value = float(got) if _pll_real(got) or isinstance(got, bool) else None\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 got = value = None\n if isinstance(got, bool) and not numbers:\n raise TypeError(\n "function_plot needs your function to give back numbers, but for x = %s it gave "\n "back %s. True and False are not numbers." % (_pll_number(x), got)\n )\n if value is None or not _math.isfinite(value):\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 _xy_chart(xs, ys, "x", "y", title, "line", markers=False, starts=starts)\n\n\n# -----------------------------------------------------------------------------\n# Numeric helpers\n# -----------------------------------------------------------------------------\n\ndef _pll_could_be_number(value):\n """A number, or text that reads as one: `"29"` from a CSV is exactly\n the case where suggesting a conversion is right."""\n try:\n return _math.isfinite(float(value.strip() if isinstance(value, str) else value))\n except (TypeError, ValueError, OverflowError):\n return False\n\n\ndef _sort_key(value, bools_are_numbers=True):\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. True and False\n are 1 and 0 - or, where they are not numbers, their own kind, after\n the numbers.\n """\n if value is None:\n return (0, 0)\n if isinstance(value, bool):\n return (1 if bools_are_numbers else 1.75, int(value))\n if _pll_real(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\ndef _pll_linear_fit(xs, ys):\n """Least squares: (slope, intercept, r_squared)."""\n if len(xs) < 2:\n raise ValueError("a line of best fit needs at least two rows")\n if len(set(xs)) == 1:\n raise ValueError("a line of best fit needs the x values to vary; they are all %g" % xs[0])\n slope, intercept = _pll_tbl_statistics.linear_regression(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 len(set(ys)) == 1 else _pll_tbl_statistics.correlation(xs, ys) ** 2\n return slope, intercept, r_squared\n\n\ndef _fit_title(slope, intercept, r_squared):\n return "y=%.3fx %s %.3f; r-sq: %.3f" % (\n slope, "+" if intercept >= 0 else "-", abs(intercept), r_squared,\n )\n\n\n# -----------------------------------------------------------------------------\n# Charts\n# -----------------------------------------------------------------------------\n#\n# A chart is SVG made into an image (`_PllChart`), so it is displayed,\n# `beside`d and annotated `-> Image` like any picture. Each is a white box\n# with a title (`_chart`), and most have a plotting area (`_plot_box`) with\n# an axis (`_axis`) along its bottom and its left, each mapping values onto\n# pixels with a `_Scale`.\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#: Series colours, 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\nclass _PllChart(_Picture):\n """A chart: an image, which displays and combines like any other."""\n\n def __init__(self, markup, width, height):\n self._markup = markup\n _Picture.__init__(self, width, height, "image/svg+xml", markup.encode(), "a chart")\n\n def to_svg(self):\n return self._markup\n\n def __repr__(self):\n return "<Chart %dx%d>" % (_pll_px(self._w), _pll_px(self._h))\n\n\ndef _plot_box(height=_CHART_H):\n """The plotting area inside a chart `height` high: (x, y, w, h)."""\n return (\n _CHART_MARGIN_L,\n _CHART_MARGIN_T,\n _CHART_W - _CHART_MARGIN_L - _CHART_MARGIN_R,\n height - _CHART_MARGIN_T - _CHART_MARGIN_B,\n )\n\n\ndef _chart(title, body, height=_CHART_H, x_label=None, y_label=None):\n """The chart: a white box with its title, and its axes\' names if it has axes."""\n px, py, pw, ph = _plot_box(height)\n words = ""\n if title:\n words += (\n \'<text x="%d" y="%d" text-anchor="middle" font-size="13" font-weight="600">%s</text>\'\n % (_CHART_W / 2, _CHART_MARGIN_T - 12, _pll_xml_escape(title))\n )\n if x_label is not None:\n words += (\n \'<text x="%d" y="%d" text-anchor="middle" font-size="11" font-style="italic">%s</text>\'\n \'<text x="%d" y="%d" text-anchor="middle" font-size="11" \'\n \'font-style="italic" transform="rotate(-90 %d %d)">%s</text>\'\n ) % (\n px + pw / 2, height - 8, _pll_xml_escape(x_label),\n 16, py + ph / 2, 16, py + ph / 2, _pll_xml_escape(y_label),\n )\n markup = (\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 ) % (_CHART_W, height, _CHART_W, height, _CHART_W, height, words, "".join(body))\n return _PllChart(markup, _CHART_W, height)\n\n\ndef _ticks(lo, hi, target):\n """About `target` round-numbered ticks covering `lo` to `hi`, each 1, 2\n or 5 times a power of ten apart."""\n if lo == hi:\n # One value: padded, so there is still an axis to draw.\n lo, hi = (-1.0, 1.0) if lo == 0 else (lo - abs(lo) * 0.5, hi + abs(hi) * 0.5)\n raw = (hi - lo) / target\n mag = 10.0 ** _math.floor(_math.log10(raw))\n step = next(nice for top, nice in ((1.5, 1.0), (3.5, 2.0), (7.5, 5.0), (_math.inf, 10.0)) if raw / mag < top) * mag\n first = _math.floor(lo / step)\n return [(first + i) * step for i in range(_math.ceil(hi / step) - first + 1)]\n\n\nclass _Scale:\n """Values from `lo` to `hi` onto pixels: from `start`, `length` long\n (negative going up the page), with round-numbered ticks along it."""\n\n def __init__(self, lo, hi, target, start, length):\n self.ticks = _ticks(lo, hi, target)\n self.lo, self.hi = self.ticks[0], self.ticks[-1]\n self.start, self.length = start, length\n\n def __call__(self, value):\n return self.start + (value - self.lo) / (self.hi - self.lo) * self.length\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 _axis(box, across, marks, lines=True):\n """An axis along the bottom of the plot (`across`) or up its left: each\n of `marks`, a (pixel, label) pair, labelled, and with a light line across\n the plot when `lines`."""\n px, py, pw, ph = box\n grid = []\n words = []\n for at, label in marks:\n if lines:\n grid.append(\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#eee"/>\'\n % ((at, py, at, py + ph) if across else (px, at, px + pw, at))\n )\n words.append(\n \'<text x="%g" y="%g" font-size="10" text-anchor="middle" fill="#444">%s</text>\'\n % (at, py + ph + 14, _pll_xml_escape(label))\n if across\n else \'<text x="%g" y="%g" font-size="10" text-anchor="end" fill="#444" \'\n \'dominant-baseline="middle">%s</text>\' % (px - 6, at, _pll_xml_escape(label))\n )\n return \'<g stroke="#bbb" stroke-width="1" fill="none">%s<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#888"/></g>%s\' % (\n "".join(grid),\n *((px, py + ph, px + pw, py + ph) if across else (px, py, px, py + ph)),\n "".join(words),\n )\n\n\ndef _numbered(scale):\n """A scale\'s ticks as an axis\'s marks."""\n return [(scale(t), _format_tick(t)) for t in scale.ticks]\n\n\ndef _label_colours(labels):\n """A colour for each label, in the order they first appear, so the key\n reads in the same order as the table."""\n colors = {}\n for label in labels:\n if label not in colors:\n colors[label] = _PALETTE[len(colors) % len(_PALETTE)]\n return colors\n\n\ndef _legend(entries, y, x=None):\n """A colour key, on a white panel so it stays readable over the data.\n\n At `x`, or else at the plot\'s right - where above eight entries it is\n left out: a wall of text, while the colours still tell the groups apart.\n """\n width = 24.0 + max(len(str(label)) for _, label in entries) * 6.2\n if x is None:\n if len(entries) > 8:\n return ""\n px, _, pw, _ = _plot_box()\n x = px + pw - width - 4\n rows = [\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="white" \'\n \'fill-opacity="0.85" stroke="#ddd"/>\' % (x, y, width, 6 + len(entries) * 14)\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"/>\' % (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 _bar_chart(labels, values, x_label, y_label, title):\n box = px, py, pw, ph = _plot_box()\n lo = min(0.0, min(values))\n hi = max(values)\n y = _Scale(lo, hi if hi != lo else lo + 1.0, 5, py + ph, -ph)\n slot = pw / len(values)\n centres = [px + slot * (i + 0.5) for i in range(len(values))]\n body = [_axis(box, False, _numbered(y)), _axis(box, True, zip(centres, labels), lines=False)]\n bar_w = slot * 0.7\n for cx, v in zip(centres, values):\n y0, yv = y(0), y(v)\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" />\'\n % (cx - bar_w / 2, min(y0, yv), bar_w, abs(y0 - yv))\n )\n return _chart(title, body, x_label=x_label, y_label=y_label)\n\n\ndef _xy_chart(xs, ys, x_label, y_label, title, mode, labels=None, lines=(), markers=True, starts=(0,)):\n """Points, joined by a line or coloured by label, and any fitted lines.\n\n `starts` are the points a line starts afresh at, after a gap.\n """\n box = px, py, pw, ph = _plot_box()\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 sx = _Scale(min(all_x), max(all_x), 6, px, pw)\n sy = _Scale(min(all_y), max(all_y), 5, py + ph, -ph)\n body = [_axis(box, False, _numbered(sy)), _axis(box, True, _numbered(sx))]\n points = [(sx(x), sy(y)) for x, y in zip(xs, ys)]\n if mode == "line":\n lifted = set(starts) | {0}\n body.append(\n \'<path d="%s" fill="none" stroke="#4f8cff" stroke-width="2" \'\n \'stroke-linejoin="round" stroke-linecap="round"/>\'\n % " ".join("%s %g %g" % ("M" if i in lifted else "L", a, b) for i, (a, b) in enumerate(points))\n )\n if markers:\n body.extend(\'<circle cx="%g" cy="%g" r="2.5" fill="#4f8cff"/>\' % p for p in points)\n elif labels is None:\n body.extend(\'<circle cx="%g" cy="%g" r="3" fill="#4f8cff" opacity="0.7"/>\' % p for p in points)\n else:\n colors = _label_colours(labels)\n for (a, b), label in zip(points, labels):\n body.append(\'<circle cx="%g" cy="%g" r="3.5" fill="%s" opacity="0.85"/>\' % (a, b, colors[label]))\n body.append(_legend([(c, label) for label, c in colors.items()], py + 4))\n for line_xs, line_ys in lines:\n body.append(\n \'<path d="%s" fill="none" stroke="#eb5757" stroke-width="2"/>\'\n % ("M " + " L ".join("%g %g" % (sx(x), sy(y)) for x, y in zip(line_xs, line_ys)))\n )\n return _chart(title, body, x_label=x_label, y_label=y_label)\n\n\ndef _pie_chart(labels, values, title, column):\n """Slices plus a key, with each slice\'s share of the total."""\n total = sum(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_literal(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 % len(_PALETTE)]\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(_legend(legend, _CHART_MARGIN_T + 4, _CHART_W * 0.62))\n return _chart(title, body)\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 _box_plot(values, name, title):\n """A box from the quartiles, whiskers to 1.5*IQR, outliers as points.\n\n Quartiles as `statistics.quantiles(..., method="inclusive")` and numpy\'s\n default percentile give them, so a student can check the box by hand.\n """\n box = px, py, pw, ph = _plot_box(_BOX_PLOT_H)\n q1, median, q3 = _pll_tbl_statistics.quantiles(values, n=4, method="inclusive")\n iqr = q3 - q1\n inside = [v for v in values if q1 - 1.5 * iqr <= v <= q3 + 1.5 * iqr] or values\n low, high = min(inside), max(inside)\n x = _Scale(min(values), max(values), 6, px, pw)\n mid = py + ph * 0.58\n half = ph * 0.26\n body = [\n _axis(box, True, _numbered(x)),\n \'<line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#444"/>\' % (x(low), mid, 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 % (x(v), mid - half * 0.5, 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 % (x(q1), mid - half, max(x(q3) - 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 % (x(median), mid - half, x(median), mid + half)\n )\n for v in values:\n if v < low or v > high:\n body.append(\'<circle cx="%g" cy="%g" r="3" fill="none" stroke="#eb5757"/>\' % (x(v), mid))\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 _chart(title, body, _BOX_PLOT_H, name, "")\n\n\n_DOT_RADIUS = 5.0\n\n\ndef _dot_plot(values, labels, name, title):\n """One dot per row, stacked where rows share a value.\n\n Sized to the tallest stack, and with no y-axis: stacking says "two rows\n had this value", which is not a quantity to put a scale against.\n """\n tally = {}\n for v in values:\n tally[v] = tally.get(v, 0) + 1\n stack_h = max(tally.values()) * (_DOT_RADIUS * 2 + 1) + _DOT_RADIUS\n colors = _label_colours(labels) if labels is not None else {}\n # The key sits inside the plot area, so the strip has to be tall enough\n # to hold it, or a tall stack at the right-hand end draws through it.\n legend_h = (6 + len(colors) * 14 + 8) if 0 < len(colors) <= 8 else 0\n chart_h = _CHART_MARGIN_T + max(64.0, stack_h + 10, legend_h + stack_h) + _CHART_MARGIN_B\n box = px, py, pw, ph = _plot_box(chart_h)\n x = _Scale(min(values), max(values), 6, px, pw)\n body = [_axis(box, True, _numbered(x))]\n seen = {}\n for i, v in enumerate(values):\n level = seen.get(v, 0)\n seen[v] = level + 1\n body.append(\n \'<circle cx="%g" cy="%g" r="%g" fill="%s" opacity="0.85"/>\'\n % (\n x(v),\n py + ph - _DOT_RADIUS - level * (_DOT_RADIUS * 2 + 1),\n _DOT_RADIUS,\n colors[labels[i]] if labels is not None else "#4f8cff",\n )\n )\n if colors:\n body.append(_legend([(c, label) for label, c in colors.items()], py + 2))\n return _chart(title, body, chart_h, name, "")\n\n\ndef _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 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 if bins > 1000:\n raise ValueError(\n "histogram would draw %d bars, and at most 1000 fit: %s."\n % (bins, "use a wider bin_width" if bin_width is not None else "ask for fewer bins")\n )\n counts = [0] * bins\n for v in values:\n counts[min(int((v - lo) / width), bins - 1)] += 1\n box = px, py, pw, ph = _plot_box()\n y = _Scale(0, max(counts), 5, py + ph, -ph)\n x = _Scale(lo, hi, 6, px, pw)\n body = [_axis(box, False, _numbered(y)), _axis(box, True, _numbered(x))]\n for i, c in enumerate(counts):\n left, right = x(lo + i * width), x(lo + i * width + width)\n # Inset slightly so bars don\'t touch.\n body.append(\n \'<rect x="%g" y="%g" width="%g" height="%g" fill="#4f8cff" opacity="0.85"/>\'\n % (left + 0.5, y(c), max(1.0, right - left - 1), y.start - y(c))\n )\n return _chart(title, body, x_label=name, y_label="count")\n\n\n#: The names the install step copies into the student\'s globals.\nPLL_TABLE_EXPORTS = "Table Row table table_from_columns load_table function_plot".split()\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';
234
234
 
235
235
  // src/python/libraries/reactor.py
236
236
  var reactor_default = '# Python Language Levels: interactive reactors (big-bang / animate).\n#\n# Loaded into Pyodide after the image library, 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 not 0 < rate < float("inf"):\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_view(running):\n """What the host needs to render the current frame."""\n current = running.current\n return {\n "frame": current.draw().to_svg(),\n "index": running.dropped + running.cursor,\n "length": running.dropped + len(running.frames),\n "atEnd": running.cursor == len(running.frames) - 1,\n "stopped": current.is_stopped(),\n "valueRepr": repr(current.get_value()),\n }\n\n\n#: A step of a reactor that is no longer registered: its session was reset.\n_PLL_REACTOR_GONE = {"kind": "gone"}\n\n\ndef _pll_reactor_failure(exc):\n """A step a handler raised in."""\n return {"kind": "raised", "error": _pll_error_info(exc)}\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 "kind": "reactor",\n "id": rid,\n "title": reactor_value.title,\n "tickRate": reactor_value.tick_rate,\n "ticking": reactor_value.handles("on_tick"),\n "wantsKeys": reactor_value.handles("on_key"),\n "wantsMouse": reactor_value.handles("on_mouse"),\n "register": reactor_value.register,\n }\n payload.update(_pll_reactor_view(running))\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 _PLL_REACTOR_GONE\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(running)\n return dict(view, kind="frame", messages=outgoing or [])\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 _PLL_REACTOR_GONE\n try:\n with _pll_as_main(running.main):\n running.seek(int(index) - running.dropped)\n return dict(_pll_reactor_view(running), kind="frame", messages=[])\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';