pll-python 0.5.0 → 0.5.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.
- package/README.md +1 -1
- package/cli.cjs +3 -3
- package/package.json +1 -1
- package/worker.cjs +7 -7
package/worker.cjs
CHANGED
|
@@ -26,8 +26,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
var import_node_worker_threads2 = require("node:worker_threads");
|
|
27
27
|
|
|
28
28
|
// src/core/files.ts
|
|
29
|
-
var MAX_FILE_BYTES =
|
|
30
|
-
var MAX_TOTAL_BYTES =
|
|
29
|
+
var MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
30
|
+
var MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
31
31
|
var MAX_FILES = 100;
|
|
32
32
|
var MB = 1024 * 1024;
|
|
33
33
|
var WHY_LEFT_OUT = {
|
|
@@ -179,7 +179,7 @@ var typeChecking_default = '# Type checking: which checks a run makes, decided b
|
|
|
179
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_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 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
|
-
var codeFacts_default = '# What the student\'s code says about an error, read from the live objects\n# and the code that raised: the definitions it names, where the names on\n# its line were last set, and how a function that returned `None` is built.\n# The explanations need these, and only Python can see them across files:\n# `shapes.area(3)` names a function defined somewhere else entirely.\n\nimport ast as _ast\nimport dataclasses as _pll_dataclasses\nimport inspect as _pll_inspect\nimport io as _pll_io\nimport keyword as _pll_keyword\nimport linecache as _pll_linecache\nimport re as _pll_src_re\nimport sys as _pll_sys\nimport tokenize as _pll_tokenize\nimport types as _pll_types\nimport typing as _pll_typing\n\n#: At most this many definitions travel with one error.\n_PLL_MAX_DEFINITIONS = 20\n\n#: The names in a message that name something defined: `area()`,\n#: `Song.__init__()`, `\'Song\' object`, `type object \'Song\'`.\n_PLL_NAMED_IN_MESSAGE = (\n _pll_src_re.compile(r"\\b([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*)\\(\\)"),\n _pll_src_re.compile(r"\'([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)*)\' object"),\n _pll_src_re.compile(r"type object \'([A-Za-z_]\\w*)\'"),\n)\n\n#: PLL\'s own namespace, which the libraries share with the bootstrap.\n_PLL_OWN = globals()\n\n_PLL_POSITIONAL = (\n _pll_inspect.Parameter.POSITIONAL_ONLY,\n _pll_inspect.Parameter.POSITIONAL_OR_KEYWORD,\n)\n\n\ndef _pll_is_own(obj):\n """Whether `obj` is one of PLL\'s own functions or classes."""\n name = getattr(obj, "__name__", None)\n return isinstance(name, str) and _PLL_OWN.get(name) is obj\n\n\ndef _pll_is_students_class(cls):\n """Whether `cls` was written in one of the student\'s files.\n\n Not by its module\'s name alone: PLL\'s libraries run as `__main__` too.\n """\n if _pll_is_own(cls):\n return False\n if cls.__module__ == "__main__":\n return True\n module = _pll_sys.modules.get(cls.__module__)\n filename = getattr(module, "__file__", None)\n return isinstance(filename, str) and _pll_is_students(filename)\n\n\ndef _pll_union_members(obj):\n """The types a union is made of, by name, or None if `obj` is not one."""\n if not (isinstance(obj, _pll_types.UnionType) or _pll_typing.get_origin(obj) is _pll_typing.Union):\n return None\n names = []\n for member in _pll_typing.get_args(obj):\n name = "None" if member is type(None) else getattr(member, "__name__", None)\n if isinstance(name, str):\n names.append(name)\n return names\n\n\ndef _pll_definition(obj, drop_first=False):\n """What `obj` is, as the explanations use it, or None.\n\n - a function: its positional `parameters`, and the `required` ones;\n `drop_first` leaves out a method\'s `self`\n - a class: whether it is the student\'s, and if so its `fields`, in\n order, and whether it is a `dataclass`\n - a union: its `members`\n """\n if isinstance(obj, type):\n students = _pll_is_students_class(obj)\n fields = []\n if students:\n if _pll_dataclasses.is_dataclass(obj):\n fields = [field.name for field in _pll_dataclasses.fields(obj)]\n else:\n fields = list(_pll_inspect.get_annotations(obj))\n return {\n "kind": "class",\n "students": students,\n "fields": fields,\n "dataclass": students and _pll_dataclasses.is_dataclass(obj),\n }\n members = _pll_union_members(obj)\n if members is not None:\n return {"kind": "union", "members": members}\n if not (_pll_inspect.isfunction(obj) or _pll_inspect.ismethod(obj)):\n return None\n try:\n parameters = list(_pll_inspect.signature(obj).parameters.values())\n except (TypeError, ValueError):\n return None\n if drop_first:\n parameters = parameters[1:]\n positional = [p for p in parameters if p.kind in _PLL_POSITIONAL]\n return {\n "kind": "function",\n "parameters": [p.name for p in positional],\n "required": [p.name for p in positional if p.default is p.empty],\n }\n\n\n_PLL_UNRESOLVED = object()\n\n\ndef _pll_resolve(chain, scopes):\n """`obj`, and whether it is a method reached through its class or one of\n its instances, for a dotted name like `shapes.area` - looked up without\n running anything (no properties, no `__getattr__`)."""\n head, *rest = chain.split(".")\n obj = _PLL_UNRESOLVED\n for scope in scopes:\n if head in scope:\n obj = scope[head]\n break\n if obj is _PLL_UNRESOLVED:\n return _PLL_UNRESOLVED, False\n method = False\n for attr in rest:\n try:\n found = _pll_inspect.getattr_static(obj, attr)\n except AttributeError:\n return _PLL_UNRESOLVED, False\n method = not isinstance(obj, _pll_types.ModuleType)\n if isinstance(found, staticmethod):\n found, method = found.__func__, False\n elif isinstance(found, classmethod):\n found = found.__func__\n obj = found\n return obj, method and _pll_inspect.isfunction(obj)\n\n\ndef _pll_line_names(text):\n """The names on a line, dotted ones whole: `shapes.area`, `print`."""\n chains = []\n current = None\n after_dot = False\n try:\n for token in _pll_tokenize.generate_tokens(_pll_io.StringIO(text).readline):\n if token.type == _pll_tokenize.NAME and not _pll_keyword.iskeyword(token.string):\n current = current + "." + token.string if after_dot and current else token.string\n after_dot = False\n continue\n if token.type == _pll_tokenize.OP and token.string == "." and current:\n after_dot = True\n continue\n if current:\n chains.append(current)\n current, after_dot = None, False\n except (_pll_tokenize.TokenError, SyntaxError):\n pass\n if current:\n chains.append(current)\n return chains\n\n\ndef _pll_definitions(exc, frame, text):\n """The definitions an explanation of `exc` might name: those its\n message names, and those named on the student\'s line.\n\n Keyed as written - `shapes.area` - and by the last part too, which is\n how Python\'s message names it (`area() missing ...`).\n """\n # PLL\'s own classes - `Table` in `Table.scatter_plot()` - are among\n # every session\'s globals.\n scopes = [frame.f_locals, frame.f_globals, frame.f_builtins]\n message = str(exc)\n chains = []\n for pattern in _PLL_NAMED_IN_MESSAGE:\n for chain in pattern.findall(message):\n parts = chain.split(".")\n # `Song.__init__()` is about `Song` as much as its `__init__`.\n chains.extend(".".join(parts[: i + 1]) for i in range(len(parts)))\n chains.extend(_pll_line_names(text or ""))\n found = {}\n # The class of the value an attribute was asked of, which no name may\n # reach: `\'Song\' object has no attribute \'yaer\'`.\n if isinstance(exc, AttributeError) and getattr(exc, "obj", None) is not None:\n cls = exc.obj if isinstance(exc.obj, type) else type(exc.obj)\n described = _pll_definition(cls)\n if described is not None:\n found[cls.__name__] = described\n for chain in chains:\n if len(found) >= _PLL_MAX_DEFINITIONS:\n break\n if chain in found:\n continue\n obj, method = _pll_resolve(chain, scopes)\n if obj is _PLL_UNRESOLVED:\n continue\n described = _pll_definition(obj, drop_first=method)\n if described is None:\n continue\n found[chain] = described\n last = chain.rsplit(".", 1)[-1]\n if last != chain and last not in found:\n found[last] = described\n return found\n\n\ndef _pll_tree_of(code, run):\n """The parsed source `code` was compiled from, as it ran, or None."""\n source = _pll_source_of(code, run)\n if not source:\n return None, ""\n try:\n with _pll_without_syntax_warnings():\n return _ast.parse(source), source\n except (SyntaxError, ValueError):\n return None, source\n\n\ndef _pll_function_node(tree, code, lineno):\n """The `def` that `code` runs, found by its name and the line inside it."""\n best = None\n for node in _ast.walk(tree):\n if (\n isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef))\n and node.name == code.co_name\n and node.lineno <= lineno <= (node.end_lineno or node.lineno)\n ):\n # The innermost, for a function defined inside another.\n if best is None or node.lineno >= best.lineno:\n best = node\n return best\n\n\ndef _pll_scope_statements(body):\n """Every statement of a scope, in order, into `if`s and loops but not\n into the functions and classes it defines."""\n for statement in body:\n yield statement\n if isinstance(statement, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n continue\n for field in ("body", "orelse", "finalbody"):\n yield from _pll_scope_statements(getattr(statement, field, None) or [])\n for handler in getattr(statement, "handlers", None) or []:\n yield from _pll_scope_statements(handler.body)\n for case in getattr(statement, "cases", None) or []:\n yield from _pll_scope_statements(case.body)\n\n\ndef _pll_dotted(node):\n """`f` or `x.append`, for the function a call calls, or None."""\n parts = []\n while isinstance(node, _ast.Attribute):\n parts.append(node.attr)\n node = node.value\n if not isinstance(node, _ast.Name):\n return None\n parts.append(node.id)\n return ".".join(reversed(parts))\n\n\ndef _pll_calls_assigned(body, names, before):\n """For each of `names` set from a call in `body`: the call and its line,\n the last before line `before` - or after it, when none is before (a\n loop goes round)."""\n earlier, later = {}, {}\n for statement in _pll_scope_statements(body):\n if isinstance(statement, _ast.Assign) and len(statement.targets) == 1:\n target, value = statement.targets[0], statement.value\n elif isinstance(statement, _ast.AnnAssign) and statement.value is not None:\n target, value = statement.target, statement.value\n else:\n continue\n if not (isinstance(target, _ast.Name) and target.id in names and isinstance(value, _ast.Call)):\n continue\n call = _pll_dotted(value.func)\n if call is None:\n continue\n side = earlier if statement.lineno < before else later\n if side is earlier or target.id not in later:\n side[target.id] = {"call": call, "line": statement.lineno}\n return {**later, **earlier}\n\n\ndef _pll_assigned(frame, lineno, tree, names):\n """Where the names on the failing line were last set from a call: in\n the function the error is in, or - for a global - in its file."""\n code = frame.f_code\n node = _pll_function_node(tree, code, lineno) if code.co_name != "<module>" else None\n local = set(code.co_varnames) if node is not None else set()\n found = {}\n if node is not None:\n found.update(_pll_calls_assigned(node.body, names & local, lineno))\n module_before = lineno if node is None else float("inf")\n for name, where in _pll_calls_assigned(tree.body, names - local, module_before).items():\n found.setdefault(name, where)\n return found\n\n\ndef _pll_union_named(annotation, frame):\n """The members of the union an annotation names, or None."""\n if isinstance(annotation, _ast.Name):\n value = frame.f_globals.get(annotation.id, frame.f_builtins.get(annotation.id))\n return _pll_union_members(value)\n if isinstance(annotation, _ast.BinOp) and isinstance(annotation.op, _ast.BitOr):\n left = _pll_union_named(annotation.left, frame)\n right = _pll_union_named(annotation.right, frame)\n names = []\n for side, part in ((left, annotation.left), (right, annotation.right)):\n if side is not None:\n names.extend(side)\n elif isinstance(part, _ast.Name):\n names.append(part.id)\n elif isinstance(part, _ast.Constant) and part.value is None:\n names.append("None")\n else:\n return None\n return names\n return None\n\n\ndef _pll_pattern_classes(pattern):\n """The classes a `case` pattern matches, or None for one that matches\n anything (`case _:`, `case x:`)."""\n if isinstance(pattern, _ast.MatchAs):\n return None if pattern.pattern is None else _pll_pattern_classes(pattern.pattern)\n if isinstance(pattern, _ast.MatchOr):\n names = set()\n for alternative in pattern.patterns:\n inner = _pll_pattern_classes(alternative)\n if inner is None:\n return None\n names |= inner\n return names\n if isinstance(pattern, _ast.MatchClass):\n name = _pll_dotted(pattern.cls)\n return {name.rsplit(".", 1)[-1]} if name else set()\n if isinstance(pattern, _ast.MatchSingleton) and pattern.value is None:\n return {"None"}\n return set()\n\n\ndef _pll_trailing_match(node, source, frame):\n """The `match` a function ends with, when it ends with one.\n\n A `match` no `case` fits does nothing, and the function then runs off\n its end and returns `None`.\n """\n last = node.body[-1] if node.body else None\n if not isinstance(last, _ast.Match):\n return None\n segment = lambda part: _ast.get_source_segment(source, part) or ""\n patterns = [case.pattern for case in last.cases]\n fixed = [\n segment(p)\n for p in patterns\n if isinstance(p, _ast.MatchSequence)\n and len(p.patterns) >= 2\n and not any(isinstance(inner, _ast.MatchStar) for inner in p.patterns)\n and segment(p).startswith("[")\n ]\n lists = [p for p in patterns if isinstance(p, _ast.MatchSequence) and segment(p).startswith("[")]\n # The members of the union being matched that no `case` names - only\n # when the subject is a parameter annotated with a union.\n uncovered = []\n subject = last.subject\n if isinstance(subject, _ast.Name):\n arguments = node.args.posonlyargs + node.args.args + node.args.kwonlyargs\n annotation = next((a.annotation for a in arguments if a.arg == subject.id), None)\n members = _pll_union_named(annotation, frame) if annotation is not None else None\n if members:\n covered = set()\n for pattern in patterns:\n classes = _pll_pattern_classes(pattern)\n if classes is None:\n covered = None\n break\n covered |= classes\n if covered is not None:\n uncovered = [member for member in members if member not in covered]\n return {\n "subject": segment(subject),\n "patterns": [segment(p) for p in patterns],\n "fixed_length": fixed,\n "has_list": len(lists) > 0,\n "uncovered": uncovered,\n }\n\n\ndef _pll_blocks(body):\n """Every block of statements in a function body, the body included,\n not into the functions and classes it defines."""\n yield body\n for statement in body:\n if isinstance(statement, (_ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)):\n continue\n for field in ("body", "orelse", "finalbody"):\n inner = getattr(statement, field, None)\n if inner:\n yield from _pll_blocks(inner)\n for handler in getattr(statement, "handlers", None) or []:\n yield from _pll_blocks(handler.body)\n for case in getattr(statement, "cases", None) or []:\n yield from _pll_blocks(case.body)\n\n\ndef _pll_print_ending_a_branch(node, source):\n """The first `print` that is the last statement of its block.\n\n A branch that ends in `print` shows the right answer and returns\n nothing. A `print` followed by more in its block is just output.\n """\n found = None\n for block in _pll_blocks(node.body):\n last = block[-1]\n call = last.value if isinstance(last, _ast.Expr) else None\n if not (isinstance(call, _ast.Call) and isinstance(call.func, _ast.Name) and call.func.id == "print"):\n continue\n if found is not None and found["line"] <= last.lineno:\n continue\n single = len(call.args) == 1 and not call.keywords and not isinstance(call.args[0], _ast.Starred)\n found = {\n "line": last.lineno,\n "expression": _ast.get_source_segment(source, call.args[0]) if single else None,\n }\n return found\n\n\ndef _pll_returned_none(frame, lineno, tree, source):\n """How the function a `None` return came from is built: the `match` it\n ends with, and a branch of it that ends in `print`."""\n node = _pll_function_node(tree, frame.f_code, lineno)\n if node is None:\n return None\n return {\n "match": _pll_trailing_match(node, source, frame),\n "printed": _pll_print_ending_a_branch(node, source),\n }\n\n\ndef _pll_code_facts(exc, run):\n """The facts about the student\'s code that explaining `exc` needs, for\n `_pll_error_info`: `definitions`, `assigned`, and - for a function\n annotated to return something that returned `None` - `returned_none`."""\n if isinstance(exc, SyntaxError):\n return {}\n frames = _pll_student_frames(exc)\n if not frames:\n return {}\n frame, lineno = frames[-1]\n text = _pll_source_line(frame.f_code, lineno, run)\n facts = {"definitions": _pll_definitions(exc, frame, text)}\n tree, source = _pll_tree_of(frame.f_code, run)\n if tree is None:\n return facts\n names = {chain.split(".")[0] for chain in _pll_line_names(text or "")}\n facts["assigned"] = _pll_assigned(frame, lineno, tree, names)\n check = (getattr(exc, "_pll_facts", None) or {}).get("check")\n if check is not None and check["kind"] == "return" and check["actual"] in ("None", "NoneType"):\n returned = _pll_returned_none(frame, lineno, tree, source)\n if returned is not None:\n facts["returned_none"] = returned\n return facts\n';
|
|
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';
|
|
183
183
|
|
|
184
184
|
// src/python/bootstrap/sessions.py
|
|
185
185
|
var sessions_default = '# Sessions and output.\n#\n# Each Python file gets its own session, keyed by an opaque string the host\n# chooses (typically the document URI). Sessions hold their own globals\n# dict, so file A\'s `data = ...` doesn\'t leak into file B\'s REPL prompt.\n# `_pll_run_file` resets the addressed session\'s globals to the baseline\n# template before executing; `_pll_repl_eval` does NOT reset, so REPL\n# input keeps the names defined by the most recent Run File of the same\n# session. The host ends a session, with `_pll_end_session`, when its file\n# is closed.\n#\n# Output - text, images, tables - goes through `_pll_push`, live to the host\n# during a file run and collected otherwise, so it arrives in the order the\n# program produced it.\n\nimport contextlib\nimport json as _pll_json\nimport sys as _sys\nimport types as _pll_types\n\n# Must match PLL_WORK_DIR in pyodide/workspace.ts. Sibling files are mounted\n# here and it is cwd, so open("cars.csv") works. It must not sit first on\n# sys.path or a neighboring pandas.py wins over the real package.\n_PLL_WORK_DIR = "/home/pyodide/pll_workspace"\n\ndef _pll_protect_import_path():\n """Keep sibling .py files from shadowing installed packages.\n\n Python puts \'\' (cwd) first on sys.path. After chdir to the work dir,\n import pandas would load a mounted pandas.py. Drop the empty entry,\n append the work dir so unique sibling modules still import, and evict\n anything already loaded from that folder (a failed pandas.py import\n leaves a poisoned sys.modules entry).\n """\n while "" in _sys.path:\n _sys.path.remove("")\n while _PLL_WORK_DIR in _sys.path:\n _sys.path.remove(_PLL_WORK_DIR)\n _sys.path.append(_PLL_WORK_DIR)\n prefix = _PLL_WORK_DIR + "/"\n for name, mod in list(_sys.modules.items()):\n filename = getattr(mod, "__file__", None)\n if isinstance(filename, str) and filename.startswith(prefix):\n _sys.modules.pop(name, None)\n\n# Per-session modules, keyed by session_key (e.g. document URI): a session\'s\n# globals are its module\'s `__dict__`. Created lazily; initialized from\n# `_pll_initial_globals`.\n_pll_sessions = {}\n\n# The "template" globals used to seed each new session and to reset a\n# session at the start of every Run File. Populated by `install.py`\n# at the end of bootstrap so the template includes the PLL image\n# library + the auto-display helper.\n_pll_initial_globals = {"__name__": "__main__", "__builtins__": __builtins__}\n\n# Display emissions captured during the most recent `_pll_run_file` /\n# `_pll_repl_eval` call. The host drains this list after the call.\n# Each entry is the event the host shows (`{"kind": "stdout"|"stderr", "text":\n# ...}`, `{"kind": "image", ...}`, or `{"kind": "table", ...}`) so the host can\n# dispatch by kind while preserving the *exact* order in which the user\'s\n# code produced them - that way a `print()` followed by a top-level table\n# shows up as text-then-table in the interactions view, even though\n# stdout and image/table emissions take different paths inside Python.\n# Pyodide is single-threaded, so a single shared list is fine.\n_pll_displays = []\n\n# When set (by either host during a file run), this is a JS callback taking\n# one JSON string. Every display payload \u2014 each stdout/stderr write, image,\n# and table \u2014 is emitted through it as it is produced, so the interactions\n# view updates *during* the run instead of only at the end. This is what\n# lets an interactive program print a prompt before input() blocks.\n# Left as None during REPL/tests, where output is delivered in one batch.\n_pll_live_emit = None\n#: Posts what `_pll_live_emit` is holding back, set beside it.\n_pll_live_flush = None\n\n\ndef _pll_push(payload):\n """Emit a display payload live, or record it for the end of the run.\n\n Exactly one of the two: when a live hook is installed the host streams\n each payload as it happens (`withLiveEmit` in pyodide/worker.ts), and\n the result does not carry it again - which would show it twice, and\n cost a list as long as a print loop\'s output.\n """\n emit = _pll_live_emit\n if emit is None:\n _pll_displays.append(payload)\n return\n try:\n emit(_pll_json.dumps(payload))\n except Exception:\n pass\n\n\nclass _PllStream:\n """Drop-in replacement for `sys.stdout` / `sys.stderr` during a run.\n\n Each `write` pushes a typed entry onto `_pll_displays` so text\n output interleaves with image/table emissions. We also keep the\n aggregate string so `result["stdout"]` / `result["stderr"]` can\n still be inspected by smoke tests and any caller that just wants\n "what did the program print?".\n """\n\n __slots__ = ("_kind", "_chunks")\n\n def __init__(self, kind):\n self._kind = kind # "stdout" or "stderr"\n self._chunks = []\n\n def write(self, s):\n if not isinstance(s, str):\n s = str(s)\n if s:\n _pll_push({"kind": self._kind, "text": s})\n self._chunks.append(s)\n return len(s)\n\n def writelines(self, lines):\n for line in lines:\n self.write(line)\n\n def flush(self):\n # `print(..., flush=True)` and `sys.stdout.flush()`: a partial line,\n # a progress dot, is shown now rather than with the next line.\n flush = _pll_live_flush\n if flush is not None:\n try:\n flush()\n except Exception:\n pass\n\n def isatty(self):\n return False\n\n def getvalue(self):\n return "".join(self._chunks)\n\n\ndef _pll_extract_display(value):\n """Try every known display protocol on `value`.\n\n Returns the display (`{"kind": "image"|"table", ...}`) or None if\n `value` doesn\'t know how to display itself.\n """\n for protocol in ("_pll_table_data", "_pll_image_data"):\n if hasattr(value, protocol):\n try:\n data = getattr(value, protocol)()\n except Exception:\n return None\n return data if isinstance(data, dict) else None\n return None\n\n\ndef _pll_matplotlib_figure(value):\n """The figure a matplotlib Figure or Axes is, or None.\n\n Looked up in `sys.modules`, so a program that never imports matplotlib\n never loads it.\n """\n figures = _sys.modules.get("matplotlib.figure")\n if figures is not None and isinstance(value, figures.Figure):\n return value\n axes = _sys.modules.get("matplotlib.axes")\n if axes is not None and isinstance(value, axes.Axes):\n return value.figure\n return None\n\n\ndef _pll_is_matplotlib(value):\n """Whether `value` is one of matplotlib\'s objects, or holds one: what\n `plt.title(...)` or `plt.plot(...)` returns, which says nothing to read."""\n if isinstance(value, (list, tuple)):\n return any(_pll_is_matplotlib(item) for item in value[:20])\n return type(value).__module__.split(".")[0] == "matplotlib"\n\n\ndef _pll_show_top_level(value):\n """Emit a value produced by a top-level expression statement.\n\n Mirrors the behavior of Python\'s interactive shell: `None` is suppressed,\n PLL images and tables are captured for the host to render, anything\n else is printed via `repr` so bare expressions like `1 + 2` still display.\n A matplotlib figure is shown as a picture; the lines and labels\n matplotlib\'s functions return are not printed.\n """\n if value is None:\n return\n # A reactor pushed its own card when it was started; printing its repr\n # as well would be noise. Duck-typed like the display protocols, so the\n # bootstrap still knows nothing about the reactor library.\n if getattr(value, "_pll_already_displayed", False):\n return\n figure = _pll_matplotlib_figure(value)\n if figure is not None:\n globals()["_pll_show_figure"](figure)\n return\n if _pll_is_matplotlib(value):\n return\n payload = _pll_extract_display(value)\n if payload is not None:\n _pll_push(payload)\n return\n print(repr(value))\n\n\n# Called by the code `_PllTopLevelExprWrapper` adds.\n_pll_hidden("show_top_level", _pll_show_top_level)\n\n\ndef _pll_session_module(session_key):\n """Get-or-create the module for `session_key`.\n\n A new session starts with `_pll_initial_globals`, so all baseline names\n like the image primitives are present.\n """\n module = _pll_sessions.get(session_key)\n if module is None:\n module = _pll_types.ModuleType("__main__")\n module.__dict__.update(_pll_initial_globals)\n _pll_sessions[session_key] = module\n return module\n\n\ndef _pll_get_session(session_key):\n """The globals of `session_key`\'s module."""\n return _pll_session_module(session_key).__dict__\n\n\n@contextlib.contextmanager\ndef _pll_as_main(module):\n """Make `module` `__main__` while the student\'s code runs in it.\n\n A file runs as `__main__`, and whatever looks a class\'s module up -\n `typing.get_type_hints`, a dataclass\'s string annotations, `pickle`,\n `import __main__` - looks in `sys.modules`. Otherwise it finds PLL\'s\n own namespace, not the student\'s.\n """\n previous = _sys.modules.get("__main__")\n _sys.modules["__main__"] = module\n try:\n yield\n finally:\n _sys.modules["__main__"] = previous\n\n\ndef _pll_end_session(session_key):\n """Forget `session_key` and everything its runs defined: its file is closed."""\n _pll_sessions.pop(session_key, None)\n\n\ndef _pll_reset_session(session_key, level):\n """Reset the globals for `session_key` to the baseline template.\n\n Mutates the existing dict in place (`clear` + `update`) so any cached\n reference to it (e.g. from `_pll_show_top_level`\'s closure or from\n Pyodide\'s `globals.get(...)`) remains valid. `__pll_level__` records the\n file\'s level, for the checks made while its code runs, whenever that is.\n """\n g = _pll_get_session(session_key)\n g.clear()\n g.update(_pll_initial_globals)\n g["__pll_level__"] = level\n return g\n';
|
|
@@ -203,7 +203,7 @@ var running_default = '# Running: a whole file, a prompt line, and whether a pro
|
|
|
203
203
|
var tests_default = '# Tests: the file\'s own `test_*` functions and `Test*` classes, collected\n# the way pytest collects them from the namespace the program made, and run\n# one at a time once it has finished - not `pytest.main()`, which cannot\n# safely be called repeatedly in one interpreter. pytest is used only to\n# rewrite assertions (`_pll_rewrite_asserts`, applied by `_pll_run_file`).\n\nimport traceback as _tb_mod\nimport ast as _ast\nimport contextlib\nimport re as _pll_src_re\n\ndef _pll_has_tests(code):\n """Return True if `code` looks like it contains pytest tests.\n\n Mirrors pytest\'s default collection: module-level `test_*` functions\n and `test_*` methods on `Test*` classes. Used so we can skip loading\n pytest for files that have no tests.\n """\n try:\n with _pll_without_syntax_warnings():\n tree = _ast.parse(code)\n except (SyntaxError, ValueError):\n return False\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n return True\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n return True\n return False\n\n\n_PLL_TRACE_SKIP = (\n _PLL_VENDOR_DIR,\n "_pytest",\n "pluggy",\n "site-packages",\n "/lib/python",\n "get_terminal_writer",\n "pyio.py",\n "pathlib",\n)\n\n\ndef _pll_is_internal_frame(line):\n return any(tok in line for tok in _PLL_TRACE_SKIP)\n\n\ndef _pll_fix_ast_ranges(tree):\n """Make rewritten asserts compile on Python 3.12+.\n\n `rewrite_asserts` can leave `end_lineno` < `lineno` on injected nodes.\n CPython then raises `ValueError: AST node line range (...) is not valid`.\n """\n for child in _ast.walk(tree):\n lineno = getattr(child, "lineno", None)\n end_lineno = getattr(child, "end_lineno", None)\n if lineno is not None and end_lineno is not None and end_lineno < lineno:\n child.end_lineno = lineno\n col = getattr(child, "col_offset", None)\n end_col = getattr(child, "end_col_offset", None)\n if (\n col is not None\n and end_col is not None\n and lineno is not None\n and end_lineno is not None\n and lineno == end_lineno\n and end_col < col\n ):\n child.end_col_offset = col\n\n\ndef _pll_test_locations(tree):\n """Map collected test names to 1-based source lines."""\n locs = {}\n for node in tree.body:\n if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if node.name.startswith("test_"):\n locs[node.name] = node.lineno\n elif isinstance(node, _ast.ClassDef) and node.name.startswith("Test"):\n for item in node.body:\n if isinstance(item, (_ast.FunctionDef, _ast.AsyncFunctionDef)):\n if item.name.startswith("test_"):\n locs[node.name + "::" + item.name] = item.lineno\n return locs\n\n\n#: A "where" line that only says what a function is: `<function approx\n#: at 0x1177>` is not something to read.\n_PLL_FUNCTION_WHERE_RE = _pll_src_re.compile(\n r"^(?:where|and)\\s+(<(?:function|built-in function|bound method|built-in method|class) [^\\n]*?>) = (.+)$"\n)\n\n\ndef _pll_friendly_assert_message(exc, tb_text):\n """Short explanation of a failed assert, with pytest internals stripped."""\n msg = str(exc).strip()\n lines = []\n for raw in msg.split("\\n"):\n stripped = raw.strip()\n if not stripped:\n continue\n if _pll_is_internal_frame(stripped):\n continue\n if stripped.startswith("+"):\n stripped = stripped.lstrip("+ ").strip()\n lines.append(stripped)\n # pytest says what each name in the assert is; for a function that is\n # its address. The name it was written as says more, so it stands in.\n for line in list(lines):\n found = _PLL_FUNCTION_WHERE_RE.match(line)\n if found is not None:\n lines.remove(line)\n lines = [other.replace(found.group(1), found.group(2)) for other in lines]\n if lines:\n return "\\n".join(lines) + _pll_float_note("\\n".join(lines))\n for raw in reversed((tb_text or "").splitlines()):\n stripped = raw.strip()\n if stripped.startswith("assert "):\n return stripped + _pll_float_note(stripped)\n return "This test failed."\n\n\n#: Two decimal numbers in a failed `assert ... == ...`.\n_PLL_FLOAT_PAIR_RE = _pll_src_re.compile(\n r"(-?\\d+\\.\\d+(?:e[-+]?\\d+)?)\\s*==\\s*(-?\\d+(?:\\.\\d+)?(?:e[-+]?\\d+)?)"\n)\n\n\ndef _pll_float_note(text):\n """" ... use pytest.approx", when two numbers differ only in the dust.\n\n `0.9299999999999999 == 0.93` is how floating point works, not a bug in\n the student\'s arithmetic, and nothing in the failure says so.\n """\n match = _PLL_FLOAT_PAIR_RE.search(text)\n if match is None:\n return ""\n try:\n left = float(match.group(1))\n right = float(match.group(2))\n except ValueError:\n return ""\n if left == right:\n return ""\n scale = max(abs(left), abs(right), 1e-12)\n if abs(left - right) / scale > 1e-6:\n return ""\n return (\n "\\nThese differ only in the last few digits, which is how decimals "\n "work in any computer. Compare them with `pytest.approx`: "\n "`assert value == pytest.approx(%s)`." % match.group(2)\n )\n\n\ndef _pll_is_async(fn):\n try:\n import inspect\n return inspect.iscoroutinefunction(fn)\n except Exception:\n return False\n\n\ndef _pll_marks(fn):\n """The pytest marks on a test: `[(name, args, kwargs)]`."""\n marks = []\n for mark in getattr(fn, "pytestmark", None) or []:\n name = getattr(mark, "name", None)\n if isinstance(name, str):\n marks.append((name, tuple(getattr(mark, "args", ())), dict(getattr(mark, "kwargs", {}))))\n return marks\n\n\ndef _pll_mark_condition(args, kwargs, fn):\n """Whether a `skipif` / `xfail` mark\'s condition holds: none is true; a\n string is evaluated in the test\'s module, as pytest does. A method\'s\n module is the one `_pll_iter_tests` noted."""\n conditions = args if args else ((kwargs["condition"],) if "condition" in kwargs else ())\n if not conditions:\n return True\n for condition in conditions:\n if isinstance(condition, str):\n try:\n module = getattr(fn, "pll_module", None) or getattr(fn, "__globals__", {})\n condition = eval(condition, module)\n except Exception:\n condition = False\n if condition:\n return True\n return False\n\n\ndef _pll_call_test(fn, run=None):\n """Run one test function, as pytest would - its `skip`, `skipif` and\n `xfail` marks included.\n\n Returns `(outcome, message, stdout, error)`. `error` is the\n `_pll_error_info` of a test that raised, for the host\'s explanations;\n the message is the one line a report has room for. A test expected to\n fail that does is "skipped", saying so; one that passes anyway passes,\n unless its mark is `strict`.\n """\n expected_failure = None\n for name, args, kwargs in _pll_marks(fn):\n reason = kwargs.get("reason") or (args[0] if name == "skip" and args else None)\n if name == "skip" or (name == "skipif" and _pll_mark_condition(args, kwargs, fn)):\n return ("skipped", reason if isinstance(reason, str) and reason else None, None, None)\n if name == "xfail" and _pll_mark_condition(args, kwargs, fn):\n expected_failure = (reason if isinstance(reason, str) else "", bool(kwargs.get("strict")))\n outcome = _pll_call_test_body(fn, run)\n if expected_failure is not None:\n reason, strict = expected_failure\n if outcome[0] in ("failed", "error"):\n said = "expected to fail" + (": " + reason if reason else "") + ", and did."\n return ("skipped", said, outcome[2], None)\n if outcome[0] == "passed" and strict:\n said = "expected to fail" + (": " + reason if reason else "") + ", but passed."\n return ("failed", said, outcome[2], None)\n return outcome\n\n\ndef _pll_call_test_body(fn, run):\n import io\n\n buf = io.StringIO()\n stops = _pll_stops_delivered\n try:\n with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):\n if _pll_is_async(fn):\n return (\n "error",\n "async tests are not supported.",\n buf.getvalue().strip() or None,\n None,\n )\n fn()\n return ("passed", None, buf.getvalue().strip() or None, None)\n except KeyboardInterrupt as e:\n # A Stop, not something this test did wrong: it ends the tests, so\n # it is not recorded as this one\'s error and passed over. One the\n # test raised itself is its error, like any other.\n if _pll_stops_delivered != stops:\n raise\n return _pll_test_error(e, buf, run)\n except AssertionError as e:\n tb_text = "".join(_tb_mod.format_exception(type(e), e, e.__traceback__))\n return (\n "failed",\n _pll_friendly_assert_message(e, tb_text),\n buf.getvalue().strip() or None,\n None,\n )\n except BaseException as e:\n name = type(e).__name__\n if name == "Skipped":\n return (\n "skipped",\n str(e).strip() or None,\n buf.getvalue().strip() or None,\n None,\n )\n if name == "XFailed":\n # `pytest.xfail("reason")`: the test says it is expected to fail.\n reason = str(e).strip()\n return (\n "skipped",\n "expected to fail" + (": " + reason if reason else "") + ".",\n buf.getvalue().strip() or None,\n None,\n )\n return _pll_test_error(e, buf, run)\n\n\ndef _pll_test_error(e, buf, run):\n """The outcome of a test that raised `e`, which was not an assertion."""\n return (\n "error",\n type(e).__name__ + ": " + (_pll_linux_errno(str(e), e) or "this test raised an exception."),\n buf.getvalue().strip() or None,\n _pll_error_info(e, run),\n )\n\n\ndef _pll_iter_tests(ns):\n """Yield (name, callable) for pytest-style tests in namespace `ns`."""\n items = list(ns.items())\n for name, obj in items:\n if name.startswith("test_") and callable(obj) and not isinstance(obj, type):\n yield name, obj\n for name, obj in items:\n if not (isinstance(obj, type) and name.startswith("Test")):\n continue\n # pytest skips classes with a custom constructor.\n try:\n obj()\n except TypeError:\n continue\n for meth_name in dir(obj):\n if not meth_name.startswith("test_"):\n continue\n attr = getattr(obj, meth_name, None)\n if not callable(attr):\n continue\n\n def _bound(cls=obj, method=meth_name):\n return getattr(cls(), method)()\n\n # pytest reads marks from the method and from its class.\n _bound.pytestmark = list(getattr(attr, "pytestmark", None) or []) + list(\n getattr(obj, "pytestmark", None) or []\n )\n _bound.pll_module = getattr(attr, "__globals__", None)\n yield name + "::" + meth_name, _bound\n\n\ndef _pll_rewrite_asserts(tree, code, filename):\n """Rewrite `tree`\'s asserts the way pytest does, so a failure says\n `assert 4 == 5` rather than nothing. In place; if it fails, the file\n still runs, with plain `AssertionError`s.\n """\n try:\n from _pytest.assertion.rewrite import rewrite_asserts\n\n rewrite_asserts(tree, code.encode("utf-8"), module_path=filename)\n import _pytest.assertion.util as _pll_assert_util\n\n _pll_assert_util._reprcompare = _pll_reprcompare\n # Do not call ast.fix_missing_locations after this: it copies parent\n # positions onto pytest\'s injected nodes and yields ranges that\n # Python 3.12+ rejects (`end_lineno` < `lineno`).\n _pll_fix_ast_ranges(tree)\n except Exception:\n # It mutates the tree as it goes, so what is compiled is whatever it\n # managed; a failure then shows an empty AssertionError, which is\n # worse than nothing but still runs.\n pass\n\n\ndef _pll_reprcompare(op, left, right):\n """What a failed comparison adds, as pytest\'s plugins add it: for two\n tables, where they differ, which their rows alone do not show.\n\n Looked up when called: the table library loads after this file.\n """\n differences = globals().get("_pll_table_differences")\n if op != "==" or differences is None:\n return None\n try:\n lines = differences(left, right)\n except Exception:\n return None\n if not lines:\n return None\n lines = ["%r == %r" % (left, right)] + lines\n # In pytest\'s own form: "~" continues the explanation, "%" is escaped.\n return "\\n~".join(line.replace("\\n", "\\\\n") for line in lines).replace("%", "%%")\n\n\ndef _pll_tests_result():\n """The tests\' part of a run\'s result, before any has run."""\n return {"passed": 0, "failed": 0, "skipped": 0, "errors": 0, "tests": []}\n\n\ndef _pll_run_collected_tests(ns, locs, run):\n """Run the `test_*` functions and `Test*` classes the program defined.\n\n `ns` is the program\'s own namespace, so the tests see exactly what it\n made; `run` is `(filename, code)` for the program, as `_pll_error_info`\n takes it. A Stop ends them: the tests that finished keep their results, the\n one running is marked as where it stopped, and the rest are not run.\n """\n result = _pll_tests_result()\n rows = result["tests"]\n current = None\n try:\n for name, fn in _pll_iter_tests(ns):\n current = name\n outcome, message, cap, error = _pll_call_test(fn, run)\n result[{"passed": "passed", "failed": "failed", "skipped": "skipped"}.get(outcome, "errors")] += 1\n rows.append({\n "name": name,\n "outcome": outcome,\n "lineNumber": locs.get(name),\n "message": message,\n "stdout": cap,\n # What it raised, explained; the host shows the finding.\n "error": error,\n })\n except KeyboardInterrupt:\n result["stopped"] = True\n result["stoppedIn"] = current\n if current is not None:\n rows.append({\n "name": current,\n "outcome": "stopped",\n "lineNumber": locs.get(current),\n "message": None,\n "stdout": None,\n "error": None,\n })\n return result\n';
|
|
204
204
|
|
|
205
205
|
// src/python/bootstrap/staticAnalysis.py
|
|
206
|
-
var staticAnalysis_default = '# Static analysis: the checks each level makes before a program runs.\n#\n# Per-level rules:\n#\n# beginner:\n# 1. Shadowing: a binding whose name appears in any enclosing\n# scope, is the name of a Python built-in, or is\n# provided by a PLL library (image / table /\n# reactor - the names every session starts with).\n# 2. Reassignment: a name bound more than once within the *same*\n# scope - or, for a `def` or `class` written\n# twice, a duplicate definition. Suppressed for\n# names already flagged as shadowing in that scope\n# (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n# 4. Silent mistakes: code that runs without a word but cannot be what\n# was meant (`_PllSilenceVisitor`).\n#\n# intermediate:\n# The same, except that reassignment is only flagged at module scope.\n# Function/lambda/class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator patterns (e.g. `total = 0;\n# for x in xs: total += x` inside `def`).\n#\n# raw, advanced:\n# No checks. Full Python.\n#\n# A "scope" is one of: module, function (incl. async), lambda, class,\n# comprehension/generator. We model these explicitly because Python 3\n# comprehensions have their own scope.\n\nimport ast as _ast\nimport sys as _sys\nimport builtins as _builtins_mod\n\n_PLL_SCOPE_FUNC = (_ast.FunctionDef, _ast.AsyncFunctionDef)\n_PLL_SCOPE_COMP = (_ast.ListComp, _ast.SetComp, _ast.DictComp, _ast.GeneratorExp)\n\n# Attributes `dir()` reports for the `builtins` *module object* itself. They\n# are not built-in functions, so reporting them as shadowed would be a false\n# positive with misleading wording ("`__name__` is the name of a Python\n# built-in"). Everything else `dir()` returns is a real builtin and is worth\n# flagging, including the dunder ones like `__import__`.\n_PLL_BUILTINS_MODULE_META = frozenset(\n ("__doc__", "__loader__", "__name__", "__package__", "__spec__")\n)\n\n\nclass _PllScope:\n __slots__ = ("node", "kind", "parent", "bindings", "declared_elsewhere")\n\n def __init__(self, node, kind, parent):\n self.node = node\n self.kind = kind # "module" | "function" | "lambda" | "class" | "comprehension"\n self.parent = parent # _PllScope | None\n # name -> [(lineno, col, kind, module), ...]. `module` is set only\n # for import bindings: the module the name was imported from.\n self.bindings = {}\n # Names this scope declared `global` or `nonlocal`. An assignment to\n # one of them rebinds the *outer* name, so it neither shadows nor\n # reassigns anything here - and the keyword itself is already\n # reported at the levels that disallow it.\n self.declared_elsewhere = set()\n\n\ndef _pll_redefines_on_purpose(node):\n """Whether a `def` is meant to share its name with one above it: a\n property\'s `@name.setter` (or `getter`, `deleter`), or an `@overload`."""\n for decorator in node.decorator_list:\n if (\n isinstance(decorator, _ast.Attribute)\n and decorator.attr in ("setter", "getter", "deleter")\n and isinstance(decorator.value, _ast.Name)\n and decorator.value.id == node.name\n ):\n return True\n if (isinstance(decorator, _ast.Name) and decorator.id == "overload") or (\n isinstance(decorator, _ast.Attribute) and decorator.attr == "overload"\n ):\n return True\n return False\n\n\ndef _pll_arg_names(args):\n """All argument names (with positions) on an ast.arguments node."""\n out = []\n posonly = list(getattr(args, "posonlyargs", []) or [])\n for a in posonly + list(args.args) + list(args.kwonlyargs):\n out.append((a.arg, a.lineno, a.col_offset))\n if args.vararg:\n out.append((args.vararg.arg, args.vararg.lineno, args.vararg.col_offset))\n if args.kwarg:\n out.append((args.kwarg.arg, args.kwarg.lineno, args.kwarg.col_offset))\n return out\n\n\nclass _PllScopeBuilder:\n """Walk a module AST and build a list of scopes with their bindings.\n\n A binding is collected in the *enclosing* scope of the syntactic node\n (e.g. a `def f` adds `f` to the surrounding scope and creates a new\n function scope for its body). Comprehensions, lambdas, and classes\n each open their own scope.\n """\n\n def __init__(self):\n self.scopes = []\n self._classes = set()\n\n def build(self, tree):\n self._classes = {node.name for node in _ast.walk(tree) if isinstance(node, _ast.ClassDef)}\n module = _PllScope(tree, "module", None)\n self.scopes.append(module)\n for stmt in tree.body:\n self._walk(stmt, module)\n return module\n\n def _add(self, scope, name, lineno, col, kind, module=None):\n scope.bindings.setdefault(name, []).append((lineno, col, kind, module))\n\n def _add_target(self, target, scope, kind):\n if isinstance(target, _ast.Name):\n self._add(scope, target.id, target.lineno, target.col_offset, kind)\n elif isinstance(target, (_ast.Tuple, _ast.List)):\n for elt in target.elts:\n self._add_target(elt, scope, kind)\n elif isinstance(target, _ast.Starred):\n self._add_target(target.value, scope, kind)\n else:\n # Subscript/Attribute targets are not name bindings.\n self._walk(target, scope)\n\n def _walk(self, node, scope):\n # --- Binding-creating statements ---------------------------------\n if isinstance(node, _ast.Assign):\n for t in node.targets:\n self._add_target(t, scope, "assign")\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AnnAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "annassign")\n else:\n self._walk(node.target, scope)\n if node.annotation is not None:\n self._walk(node.annotation, scope)\n if node.value is not None:\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AugAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "augassign")\n else:\n self._walk(node.target, scope)\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.For, _ast.AsyncFor)):\n self._add_target(node.target, scope, "for")\n self._walk(node.iter, scope)\n for s in node.body:\n self._walk(s, scope)\n for s in node.orelse:\n self._walk(s, scope)\n return\n if isinstance(node, (_ast.With, _ast.AsyncWith)):\n for item in node.items:\n self._walk(item.context_expr, scope)\n if item.optional_vars is not None:\n self._add_target(item.optional_vars, scope, "with")\n for s in node.body:\n self._walk(s, scope)\n return\n if isinstance(node, _ast.Import):\n for alias in node.names:\n name = alias.asname or alias.name.split(".", 1)[0]\n self._add(scope, name, node.lineno, node.col_offset, "import", alias.name)\n return\n if isinstance(node, _ast.ImportFrom):\n for alias in node.names:\n name = alias.asname or alias.name\n self._add(scope, name, node.lineno, node.col_offset, "importfrom", node.module)\n return\n if isinstance(node, _ast.NamedExpr):\n # Walrus :=\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "walrus")\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n # No binding here, but an assignment further down binds the\n # outer name rather than a local one. Recorded so the shadowing\n # and reassignment checks do not report it twice over.\n scope.declared_elsewhere.update(node.names)\n return\n # A `case` pattern\'s captures, and `except ... as e`, bind a name -\n # one of the alternatives, of which only one runs. Not `case Boa:`\n # for a class `Boa`: that is the class with its brackets left off,\n # which the compiler\'s own error, or the match it then fits, says.\n if (\n isinstance(node, (_ast.MatchAs, _ast.MatchStar))\n and node.name is not None\n and not (isinstance(node, _ast.MatchAs) and node.pattern is None and node.name in self._classes)\n ):\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.MatchMapping) and node.rest is not None:\n self._add(scope, node.rest, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.ExceptHandler) and node.name is not None:\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n kind = "accessor" if _pll_redefines_on_purpose(node) else "functiondef"\n self._add(scope, node.name, node.lineno, node.col_offset, kind)\n for d in node.decorator_list:\n self._walk(d, scope)\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "function", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _ast.Lambda):\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "lambda", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n self._walk(node.body, inner)\n return\n if isinstance(node, _ast.ClassDef):\n self._add(scope, node.name, node.lineno, node.col_offset, "classdef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for b in node.bases:\n self._walk(b, scope)\n for kw in node.keywords:\n self._walk(kw.value, scope)\n inner = _PllScope(node, "class", scope)\n self.scopes.append(inner)\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _PLL_SCOPE_COMP):\n inner = _PllScope(node, "comprehension", scope)\n self.scopes.append(inner)\n # Per Python semantics: outermost iter is evaluated in the OUTER\n # scope, everything else inside the comp scope.\n for i, gen in enumerate(node.generators):\n if i == 0:\n self._walk(gen.iter, scope)\n else:\n self._walk(gen.iter, inner)\n self._add_target(gen.target, inner, "comprehension")\n for cond in gen.ifs:\n self._walk(cond, inner)\n if isinstance(node, _ast.DictComp):\n self._walk(node.key, inner)\n self._walk(node.value, inner)\n else:\n self._walk(node.elt, inner)\n return\n\n # --- Default: descend in the same scope --------------------------\n for child in _ast.iter_child_nodes(node):\n self._walk(child, scope)\n\n\n#: The kinds that are said but do not stop the code running: each can be\n#: right as written. Every other kind is an error, which does.\n_PLL_WARNING_KINDS = frozenset(("method-not-called", "test-not-named"))\n\n\ndef _pll_finding(kind, error_type, line, column, name_token=None, **extras):\n """One static finding, in the shape `_pll_word_static` words.\n\n `extras` are what this kind\'s explanation needs, and only that: the\n checks that walk scopes add `scope_kind`, because only they know it.\n """\n finding = {\n "id": kind,\n "error_type": error_type,\n "severity": "warning" if kind in _PLL_WARNING_KINDS else "error",\n "line_number": line,\n "column": column,\n "name_token": name_token,\n }\n finding.update(extras)\n return finding\n\n\n#: Annotations students write that are not types, and the type each should\n#: be: `table` is the function that makes a table, `Table` the type. Written\n#: out rather than derived, so a name is only reported when its replacement\n#: is certainly right, and is a name a student\'s code has. A row is a\n#: `dict`, the type the course gives for one; PLL\'s `Row` subclasses it.\n_PLL_NOT_A_TYPE = {\n "table": "Table",\n "reactor": "Reactor",\n "row": "dict",\n "string": "str",\n "integer": "int",\n "boolean": "bool",\n "number": "float",\n "Float": "float",\n "Int": "int",\n "Str": "str",\n "Bool": "bool",\n "image": "Image",\n "Number": "float",\n "String": "str",\n "Boolean": "bool",\n "Integer": "int",\n}\n\n#: Methods that are nearly always meant to be called. `movies["rating"].mean`\n#: prints `<bound method Series.mean of ...>` and says nothing; `.mean()` is\n#: what was wanted. Only names that are a method everywhere a student meets\n#: them: `columns`, `values` and `shape` are properties in pandas, and\n#: `width` and `height` are properties on an image, so they are not here.\n_PLL_CALLED_METHODS = frozenset(\n (\n "mean",\n "sum",\n "count",\n "length",\n "rows",\n "head",\n "tail",\n "to_pandas",\n "to_svg",\n "upper",\n "lower",\n "strip",\n "split",\n "keys",\n "items",\n "sort_values",\n "value_counts",\n "describe",\n "median",\n "std",\n "var",\n "nunique",\n "interact",\n )\n)\n\n\n#: Functions whose whole job is to be given a function, where naming a\n#: method without calling it is exactly right.\n_PLL_TAKES_A_FUNCTION = frozenset(\n ("sorted", "map", "filter", "min", "max", "sort", "reduce", "any", "all")\n)\n\n#: Types a student might assign to a field name by mistake: `year = int`\n#: rather than `year: int`.\n_PLL_TYPE_NAMES = frozenset(("int", "float", "str", "bool", "list", "dict", "tuple"))\n\n#: Built-in types whose methods are passed as functions: `str.upper` is the\n#: function every string\'s `.upper()` calls, and can never be one forgotten.\n_PLL_METHOD_TYPES = frozenset(("str", "int", "float", "list", "dict", "set", "tuple", "bytes", "frozenset"))\n\n\ndef _pll_names_type(annotation):\n """Whether an annotation is `type` or `type[...]`."""\n if isinstance(annotation, _ast.Subscript):\n annotation = annotation.value\n return isinstance(annotation, _ast.Name) and annotation.id == "type"\n\n\nclass _PllSilenceVisitor(_ast.NodeVisitor):\n """Collect mistakes that run without a word being said.\n\n Each of these is valid Python that does nothing, or does something\n other than what was meant, and so produces no error at all:\n\n - a value computed and thrown away inside a function, which is how a\n test written without `assert` always passes;\n - `assert(x, 1)`, where the tuple is always true;\n - a function containing `assert` that is not named `test_...` and is\n never called, so it never runs;\n - an annotation naming a function rather than a type, which turns\n every check on that value off;\n - a method named but not called, which yields the method object;\n - a dataclass field with no type, or written `year = int`, which\n makes no field and goes wrong somewhere else entirely;\n - a class with fields and no `@dataclass`, whose constructor then\n "takes no arguments";\n - `a == Boa`, which is always False.\n\n Only inside a function for the first one: at the top level a bare\n expression is displayed, and the course relies on that.\n """\n\n __slots__ = (\n "found",\n "_code",\n "_defined",\n "_depth",\n "_called",\n "_asserting",\n "_classes",\n "_fields",\n "_type_valued",\n )\n\n def __init__(self, code, defined):\n self.found = []\n # The student\'s text, so advice can quote `return order_amt + 4`\n # rather than say "`return` it".\n self._code = code\n # Every name the file (or the session) binds at the top level: an\n # alias like `Number = int | float` is a type an annotation can name.\n self._defined = defined\n self._depth = 0\n # For each function being visited, its parameters annotated `type`,\n # whose values are classes and can be compared with one.\n self._type_valued = []\n # Classes defined in this file, so `== Boa` can be told from `== b`.\n self._classes = set()\n # Their field names. A field called `count` or `items` happens to\n # share its name with a method, and `s.count` is then exactly\n # right - so those are not "a method you forgot to call".\n self._fields = set()\n # Names used anywhere other than as the function\'s own definition,\n # so a helper that is never called can be told from one that is.\n self._called = set()\n # A finding for each function that contains an `assert` and is not\n # a test - reported only if nothing calls it, which is known at the end.\n self._asserting = []\n\n # ---- functions ----\n\n def scan(self, tree):\n """The findings for `tree`, with the classes and their fields collected first.\n\n `a == Boa` is only recognisable as a comparison against a class if\n `Boa` is already known, and a function written above the class it\n uses is perfectly ordinary. The field names are wanted for the same\n reason, by the checks that would otherwise mistake one for a method.\n """\n for node in _ast.walk(tree):\n if not isinstance(node, _ast.ClassDef):\n continue\n self._classes.add(node.name)\n for stmt in node.body:\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name):\n self._fields.add(stmt.target.id)\n self.visit(tree)\n self.found.extend(f for f in self._asserting if f["name_token"] not in self._called)\n return self.found\n\n def _function(self, node):\n self._depth += 1\n self._type_valued.append({\n arg.arg\n for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs\n if _pll_names_type(arg.annotation)\n })\n self.generic_visit(node)\n self._type_valued.pop()\n self._depth -= 1\n if self._contains_assert(node) and not node.name.startswith("test_"):\n self._asserting.append(\n _pll_finding("test-not-named", "NeverRun", node.lineno, node.col_offset, node.name)\n )\n self._not_a_type(node.returns)\n\n visit_FunctionDef = _function\n visit_AsyncFunctionDef = _function\n\n @staticmethod\n def _contains_assert(node):\n for child in _ast.walk(node):\n if isinstance(child, _ast.Assert):\n return True\n return False\n\n def visit_Name(self, node):\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.id)\n self.generic_visit(node)\n\n def visit_Attribute(self, node):\n # A method is called through its object: `a.check()`.\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.attr)\n self.generic_visit(node)\n\n # ---- classes and their fields ----\n\n def visit_ClassDef(self, node):\n self._classes.add(node.name)\n decorated = any(\n (isinstance(d, _ast.Name) and d.id == "dataclass")\n or (isinstance(d, _ast.Attribute) and d.attr == "dataclass")\n or (\n isinstance(d, _ast.Call)\n and (\n (isinstance(d.func, _ast.Name) and d.func.id == "dataclass")\n or (isinstance(d.func, _ast.Attribute) and d.func.attr == "dataclass")\n )\n )\n for d in node.decorator_list\n )\n annotated = [\n stmt\n for stmt in node.body\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name)\n ]\n for stmt in node.body:\n # `year` on a line of its own: meant as a field, but it is a\n # use of a name, so it fails as a NameError somewhere else.\n if isinstance(stmt, _ast.Expr) and isinstance(stmt.value, _ast.Name):\n self.found.append(\n _pll_finding(\n "field-no-type", "FieldNeedsType", stmt.lineno, stmt.col_offset, stmt.value.id\n )\n )\n # `year = int`: a class attribute holding a type, which makes\n # no field at all and goes wrong much later, in the argument\n # count of a constructor nobody wrote.\n if (\n isinstance(stmt, _ast.Assign)\n and len(stmt.targets) == 1\n and isinstance(stmt.targets[0], _ast.Name)\n and isinstance(stmt.value, _ast.Name)\n and stmt.value.id in _PLL_TYPE_NAMES\n ):\n self.found.append(\n _pll_finding(\n "field-assigned-type",\n "FieldNeedsType",\n stmt.lineno,\n stmt.col_offset,\n stmt.targets[0].id,\n # The type they wrote, so the fix quotes it back exactly.\n written_type=stmt.value.id,\n )\n )\n # Annotated fields and no `@dataclass`: `X(...)` then fails with\n # "takes no arguments", which says nothing about the decorator. Only\n # for a class with no base: a `NamedTuple` declares its fields this\n # way, and a subclass is made by its base\'s constructor.\n writes_init = any(\n isinstance(stmt, _PLL_SCOPE_FUNC) and stmt.name == "__init__"\n for stmt in node.body\n )\n has_base = any(not (isinstance(b, _ast.Name) and b.id == "object") for b in node.bases)\n if annotated and not decorated and not writes_init and not has_base:\n self.found.append(\n _pll_finding("class-needs-dataclass", "NotADataclass", node.lineno, node.col_offset, node.name)\n )\n self.generic_visit(node)\n\n def _class_valued(self, side):\n """Whether `side` is a class itself, which a class can equal:\n `type(a)`, `a.__class__`, another class, or a parameter annotated\n `type`."""\n if isinstance(side, _ast.Call) and isinstance(side.func, _ast.Name) and side.func.id == "type":\n return True\n if isinstance(side, _ast.Attribute) and side.attr == "__class__":\n return True\n return isinstance(side, _ast.Name) and (\n side.id in self._classes or any(side.id in names for names in self._type_valued)\n )\n\n def visit_Compare(self, node):\n # `if a == Boa:` is always False - a value is never equal to the\n # class it was made from. `type(a) == Boa`, though, is a real check.\n sides = [node.left] + list(node.comparators)\n # `x is 60.5`, `name is "Ada"`: `is` asks whether two are the same\n # object, which a number or a string written out need not be -\n # Python\'s SyntaxWarning, as a finding of the level\'s.\n for op, left, right in zip(node.ops, sides, sides[1:]):\n if isinstance(op, (_ast.Is, _ast.IsNot)):\n literal = next(\n (\n side\n for side in (left, right)\n if isinstance(side, _ast.Constant)\n and side.value is not None\n and not isinstance(side.value, bool)\n and side.value is not Ellipsis\n ),\n None,\n )\n if literal is not None:\n self.found.append(\n _pll_finding(\n "is-literal",\n "WrongComparison",\n node.lineno,\n node.col_offset,\n operator="is not" if isinstance(op, _ast.IsNot) else "is",\n literal=_ast.get_source_segment(self._code, literal),\n )\n )\n if any(isinstance(op, (_ast.Eq, _ast.NotEq)) for op in node.ops):\n for i, side in enumerate(sides):\n others = sides[:i] + sides[i + 1 :]\n if (\n isinstance(side, _ast.Name)\n and side.id in self._classes\n and not any(self._class_valued(other) for other in others)\n ):\n self.found.append(\n _pll_finding("compared-with-class", "AlwaysFalse", side.lineno, side.col_offset, side.id)\n )\n self.generic_visit(node)\n\n # ---- statements whose value goes nowhere ----\n\n def visit_Expr(self, node):\n value = node.value\n # A method named but not called is the more specific thing to say\n # about `t.mean` on a line of its own, so it wins.\n if not self._method_not_called(value) and self._depth > 0 and self._discarded(value):\n self.found.append(\n _pll_finding(\n "unused-comparison" if isinstance(value, _ast.Compare) else "unused-value",\n "UnusedValue",\n node.lineno,\n node.col_offset,\n expression=_ast.get_source_segment(self._code, value),\n )\n )\n self.generic_visit(node)\n\n @staticmethod\n def _discarded(value):\n """Whether this expression statement can only be a mistake.\n\n A call, an await or a yield is there for its effect. A string is a\n docstring. `...` is a placeholder. Everything else computes\n something and drops it.\n """\n if isinstance(\n value,\n (\n _ast.Call,\n _ast.Await,\n _ast.Yield,\n _ast.YieldFrom,\n _ast.NamedExpr,\n ),\n ):\n return False\n if isinstance(value, _ast.Constant):\n return False\n return True\n\n def _method_not_called(self, value):\n """`movies["rating"].mean` - the method itself, not its result."""\n if (\n isinstance(value, _ast.Attribute)\n and value.attr in _PLL_CALLED_METHODS\n and value.attr not in self._fields\n # `str.upper`: read off a type, so the function, on purpose.\n and not (\n isinstance(value.value, _ast.Name)\n and (value.value.id in _PLL_METHOD_TYPES or value.value.id in self._classes)\n )\n ):\n self.found.append(\n _pll_finding("method-not-called", "NotCalled", value.lineno, value.col_offset, value.attr)\n )\n return True\n return False\n\n def visit_Call(self, node):\n # A method named but not called, wherever its value is used:\n # `print(movies["rating"].mean)`.\n #\n # Not for a function that takes a function - `sorted(xs,\n # key=str.lower)` passes `str.lower` deliberately, and that is the\n # one shape where naming a method without calling it is right.\n callee = node.func\n name = callee.id if isinstance(callee, _ast.Name) else None\n if name in _PLL_TAKES_A_FUNCTION:\n self.generic_visit(node)\n return\n for keyword in node.keywords:\n if keyword.arg not in ("key", "default_factory"):\n self._method_not_called(keyword.value)\n for argument in node.args:\n self._method_not_called(argument)\n self.generic_visit(node)\n\n # ---- asserts ----\n\n def visit_Assert(self, node):\n if isinstance(node.test, _ast.Tuple) and node.test.elts:\n self.found.append(_pll_finding("assert-tuple", "AlwaysTrue", node.lineno, node.col_offset))\n self.generic_visit(node)\n\n # ---- annotations ----\n\n def _not_a_type(self, annotation):\n if (\n isinstance(annotation, _ast.Name)\n and annotation.id in _PLL_NOT_A_TYPE\n # A class or alias of their own called `Number` is a type, and\n # naming it in an annotation is right.\n and annotation.id not in self._classes\n and annotation.id not in self._defined\n ):\n self.found.append(\n _pll_finding(\n "annotation-not-a-type", "NotAType", annotation.lineno, annotation.col_offset, annotation.id\n )\n )\n\n def _annotation(self, node):\n self._not_a_type(getattr(node, "annotation", None))\n self.generic_visit(node)\n\n visit_AnnAssign = _annotation\n visit_arg = _annotation\n\n\ndef _pll_session_bound_names(session_key):\n """User-defined names already bound in a session (for REPL checks).\n\n Baseline names from `_pll_initial_globals` (image primitives, etc.) are\n skipped unless the user rebound them, so prompt analysis matches file\n analysis for the same snippet.\n """\n module = _pll_sessions.get(session_key)\n if module is None:\n return []\n g = module.__dict__\n names = []\n for n in g:\n if n.startswith("_") or n in ("__builtins__", "__name__", "__doc__"):\n continue\n if n in _pll_initial_globals and g[n] is _pll_initial_globals[n]:\n continue\n names.append(n)\n return names\n\n\ndef _pll_predefined_library_names():\n """Public names every session starts with, mapped to their library.\n\n `install.py` seeds `_pll_initial_globals` with the image / table\n / reactor exports, so those names (`circle`, `rectangle`, `table`,\n `animate`, ...) are bound before the student writes anything. A student\n definition that reuses one is shadowing, exactly like a built-in - just\n with wording that says where the name comes from. The label is derived\n from the registered `pll` package when it is loaded, and falls back to\n the generic "library" otherwise (e.g. bootstrap-only test harnesses).\n """\n public = {n for n in _pll_initial_globals if not n.startswith("_")}\n labels = {}\n pll = _sys.modules.get("pll")\n if pll is not None:\n for lib in ("image", "table", "reactor"):\n mod = getattr(pll, lib, None)\n if mod is None:\n continue\n for n in public:\n if hasattr(mod, n):\n labels[n] = lib\n return {n: labels.get(n, "library") for n in public}\n\n\ndef _pll_is_pll_import_loc(loc):\n """True when a binding loc tuple imports from a `pll` module.\n\n `from pll.image import circle` re-binds the library\'s own value, so it\n shadows nothing and must not be flagged.\n """\n kind, module = loc[2], loc[3]\n return (\n kind in ("import", "importfrom")\n and isinstance(module, str)\n and (module == "pll" or module.startswith("pll."))\n )\n\n\ndef _pll_refuses_reassignment(level, scope_kind):\n """Whether `level` refuses a name assigned twice in a scope of `scope_kind`.\n\n Everywhere at beginner; at intermediate only at module scope, so that a\n function can keep a running total (`total = 0; for x in xs: total += x`).\n The wording asks it too, to offer only fixes the level accepts.\n """\n if level == _PLL_LEVEL_BEGINNER:\n return True\n return level == _PLL_LEVEL_INTERMEDIATE and scope_kind == "module"\n\n\ndef _pll_static_analyze(code, level, filename, session_key=None):\n """Run static checks for `level` over `code` and return findings.\n\n Returns a list of dicts made by `_pll_finding`: `id`, `error_type`, a\n position and `name_token`, and whatever that kind\'s explanation needs.\n\n If `session_key` is set, names already bound in that session are treated\n as existing module-level bindings. That way a `#level beginner` prompt cannot\n reassign a name the file (or an earlier prompt line) already defined.\n """\n if level not in _PLL_TEACHING_LEVELS:\n return []\n try:\n with _pll_without_syntax_warnings():\n tree = _ast.parse(code, filename=filename)\n except SyntaxError:\n # Let the runtime path surface SyntaxErrors with their normal flow.\n return []\n\n findings = []\n builder = _PllScopeBuilder()\n builder.build(tree)\n if session_key:\n module = builder.scopes[0]\n for name in _pll_session_bound_names(session_key):\n locs = module.bindings.setdefault(name, [])\n locs.insert(0, (0, 0, "preexisting", None))\n builtins_set = set(dir(_builtins_mod)) - _PLL_BUILTINS_MODULE_META\n library_names = _pll_predefined_library_names()\n\n for scope in builder.scopes:\n # Names visible from any enclosing scope, paired with the *nearest*\n # outer binding\'s location and scope kind. We walk parents inner-to-\n # outer and refuse to overwrite, so the closest enclosing binding wins\n # (which is the one Python\'s lookup rules would resolve to).\n enclosing = {} # name -> (lineno, col, scope_kind, binding kind)\n cur = scope.parent\n while cur is not None:\n for outer_name, outer_locs in cur.bindings.items():\n if outer_name in enclosing:\n continue\n outer_first = outer_locs[0]\n enclosing[outer_name] = (outer_first[0], outer_first[1], cur.kind, outer_first[2])\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n if name in scope.declared_elsewhere:\n # `global x` has its own finding; one for the same name here\n # would read as a second, separate mistake.\n continue\n if scope.kind == "class":\n # A name bound in a class body is an *attribute*, not a\n # variable. `id: int` in a dataclass declares a field, and\n # `id` everywhere else still finds the built-in, so nothing\n # is shadowed. The class\'s own name is bound in the\n # enclosing scope and checked there, so `class list:` is\n # still caught.\n continue\n first_loc = locs[0]\n # A binding imported from a `pll` module re-binds the library\'s\n # own value and shadows nothing; a "preexisting" marker records\n # a name the session already bound (REPL analysis) rather than\n # one this code defines. Report at the first binding that\n # actually (re)defines the name; when there is none, this name\n # has nothing to report here.\n defining_loc = None\n for loc in locs:\n if not _pll_is_pll_import_loc(loc) and loc[2] != "preexisting":\n defining_loc = loc\n break\n report_loc = defining_loc if defining_loc is not None else first_loc\n if name in enclosing:\n shadowed_in_scope.add(name)\n outer = enclosing[name]\n findings.append(_pll_finding(\n "shadowing", "Shadowing", report_loc[0], report_loc[1], name,\n scope_kind=scope.kind,\n binding=report_loc[2],\n outer_line_number=outer[0],\n outer_column=outer[1],\n outer_scope_kind=outer[2],\n outer_binding=outer[3],\n ))\n elif name in builtins_set and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append(_pll_finding(\n "shadowing-builtin", "Shadowing", defining_loc[0], defining_loc[1], name,\n scope_kind=scope.kind,\n ))\n elif name in library_names and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append(_pll_finding(\n "shadowing-library", "Shadowing", defining_loc[0], defining_loc[1], name,\n scope_kind=scope.kind,\n library=library_names[name],\n binding=defining_loc[2],\n module=defining_loc[3],\n ))\n\n # ---- Then reassignment (skip names already shadow-flagged) ----\n if _pll_refuses_reassignment(level, scope.kind):\n for name, locs in scope.bindings.items():\n if name in shadowed_in_scope or name in scope.declared_elsewhere:\n continue\n # A property\'s setter is the property, not a second one.\n locs = [loc for loc in locs if loc[2] != "accessor"]\n # The same capture in two `case`s (or two `except`s) is bound\n # by whichever one runs, never twice.\n if all(loc[2] == "capture" for loc in locs):\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n # A `def` written twice is not an accumulator: the\n # advice for a reassigned variable (running totals, use\n # `sum`) is about something else entirely, and the fix\n # is to rename one of them.\n definitions = {"functiondef": "function", "classdef": "class"}\n both = definitions.get(first_loc[2]) if first_loc[2] == second_loc[2] else None\n if both is not None:\n findings.append(_pll_finding(\n "duplicate-definition", "DuplicateDefinition", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n definition_kind=both,\n ))\n continue\n findings.append(_pll_finding(\n "reassignment", "Reassignment", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n ))\n\n # ---- `global` / `nonlocal` ----\n # Both are disallowed at beginner and intermediate. One finding per\n # statement, not per name, so `global x, y` is one diagnostic on its line.\n for node in _ast.walk(tree):\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n findings.append(_pll_finding(\n "disallowed-keyword", "DisallowedKeyword", node.lineno, node.col_offset, node.names[0],\n keyword="global" if isinstance(node, _ast.Global) else "nonlocal",\n names=list(node.names),\n ))\n\n # ---- mistakes that would otherwise run without a word ----\n findings.extend(_PllSilenceVisitor(code, set(builder.scopes[0].bindings)).scan(tree))\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
|
|
206
|
+
var staticAnalysis_default = '# Static analysis: the checks each level makes before a program runs.\n#\n# Per-level rules:\n#\n# beginner:\n# 1. Shadowing: a binding whose name appears in any enclosing\n# scope, is the name of a Python built-in, or is\n# provided by a PLL library (image / table /\n# reactor - the names every session starts with).\n# 2. Reassignment: a name bound more than once within the *same*\n# scope - or, for a `def` or `class` written\n# twice, a duplicate definition. Suppressed for\n# names already flagged as shadowing in that scope\n# (fix the shadow first).\n# 3. Disallowed kw: `global` and `nonlocal` statements.\n# 4. Silent mistakes: code that runs without a word but cannot be what\n# was meant (`_PllSilenceVisitor`).\n#\n# intermediate:\n# The same, except that reassignment is only flagged at module scope.\n# Function/lambda/class/comprehension scopes are allowed to rebind,\n# which is what enables for-loop accumulator patterns (e.g. `total = 0;\n# for x in xs: total += x` inside `def`).\n#\n# raw, advanced:\n# No checks. Full Python.\n#\n# A "scope" is one of: module, function (incl. async), lambda, class,\n# comprehension/generator. We model these explicitly because Python 3\n# comprehensions have their own scope.\n\nimport ast as _ast\nimport sys as _sys\nimport builtins as _builtins_mod\n\n_PLL_SCOPE_FUNC = (_ast.FunctionDef, _ast.AsyncFunctionDef)\n_PLL_SCOPE_COMP = (_ast.ListComp, _ast.SetComp, _ast.DictComp, _ast.GeneratorExp)\n\n# Attributes `dir()` reports for the `builtins` *module object* itself. They\n# are not built-in functions, so reporting them as shadowed would be a false\n# positive with misleading wording ("`__name__` is the name of a Python\n# built-in"). Everything else `dir()` returns is a real builtin and is worth\n# flagging, including the dunder ones like `__import__`.\n_PLL_BUILTINS_MODULE_META = frozenset(\n ("__doc__", "__loader__", "__name__", "__package__", "__spec__")\n)\n\n\nclass _PllScope:\n __slots__ = ("node", "kind", "parent", "bindings", "declared_elsewhere")\n\n def __init__(self, node, kind, parent):\n self.node = node\n self.kind = kind # "module" | "function" | "lambda" | "class" | "comprehension"\n self.parent = parent # _PllScope | None\n # name -> [(lineno, col, kind, module), ...]. `module` is set only\n # for import bindings: the module the name was imported from.\n self.bindings = {}\n # Names this scope declared `global` or `nonlocal`. An assignment to\n # one of them rebinds the *outer* name, so it neither shadows nor\n # reassigns anything here - and the keyword itself is already\n # reported at the levels that disallow it.\n self.declared_elsewhere = set()\n\n\ndef _pll_redefines_on_purpose(node):\n """Whether a `def` is meant to share its name with one above it: a\n property\'s `@name.setter` (or `getter`, `deleter`), or an `@overload`."""\n for decorator in node.decorator_list:\n if (\n isinstance(decorator, _ast.Attribute)\n and decorator.attr in ("setter", "getter", "deleter")\n and isinstance(decorator.value, _ast.Name)\n and decorator.value.id == node.name\n ):\n return True\n if (isinstance(decorator, _ast.Name) and decorator.id == "overload") or (\n isinstance(decorator, _ast.Attribute) and decorator.attr == "overload"\n ):\n return True\n return False\n\n\ndef _pll_arg_names(args):\n """All argument names (with positions) on an ast.arguments node."""\n out = []\n posonly = list(getattr(args, "posonlyargs", []) or [])\n for a in posonly + list(args.args) + list(args.kwonlyargs):\n out.append((a.arg, a.lineno, a.col_offset))\n if args.vararg:\n out.append((args.vararg.arg, args.vararg.lineno, args.vararg.col_offset))\n if args.kwarg:\n out.append((args.kwarg.arg, args.kwarg.lineno, args.kwarg.col_offset))\n return out\n\n\nclass _PllScopeBuilder:\n """Walk a module AST and build a list of scopes with their bindings.\n\n A binding is collected in the *enclosing* scope of the syntactic node\n (e.g. a `def f` adds `f` to the surrounding scope and creates a new\n function scope for its body). Comprehensions, lambdas, and classes\n each open their own scope.\n """\n\n def __init__(self):\n self.scopes = []\n self._classes = set()\n\n def build(self, tree):\n self._classes = {node.name for node in _ast.walk(tree) if isinstance(node, _ast.ClassDef)}\n module = _PllScope(tree, "module", None)\n self.scopes.append(module)\n for stmt in tree.body:\n self._walk(stmt, module)\n return module\n\n def _add(self, scope, name, lineno, col, kind, module=None):\n scope.bindings.setdefault(name, []).append((lineno, col, kind, module))\n\n def _add_target(self, target, scope, kind):\n if isinstance(target, _ast.Name):\n self._add(scope, target.id, target.lineno, target.col_offset, kind)\n elif isinstance(target, (_ast.Tuple, _ast.List)):\n for elt in target.elts:\n self._add_target(elt, scope, kind)\n elif isinstance(target, _ast.Starred):\n self._add_target(target.value, scope, kind)\n else:\n # Subscript/Attribute targets are not name bindings.\n self._walk(target, scope)\n\n def _walk(self, node, scope):\n # --- Binding-creating statements ---------------------------------\n if isinstance(node, _ast.Assign):\n for t in node.targets:\n self._add_target(t, scope, "assign")\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AnnAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "annassign")\n else:\n self._walk(node.target, scope)\n if node.annotation is not None:\n self._walk(node.annotation, scope)\n if node.value is not None:\n self._walk(node.value, scope)\n return\n if isinstance(node, _ast.AugAssign):\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "augassign")\n else:\n self._walk(node.target, scope)\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.For, _ast.AsyncFor)):\n self._add_target(node.target, scope, "for")\n self._walk(node.iter, scope)\n for s in node.body:\n self._walk(s, scope)\n for s in node.orelse:\n self._walk(s, scope)\n return\n if isinstance(node, (_ast.With, _ast.AsyncWith)):\n for item in node.items:\n self._walk(item.context_expr, scope)\n if item.optional_vars is not None:\n self._add_target(item.optional_vars, scope, "with")\n for s in node.body:\n self._walk(s, scope)\n return\n if isinstance(node, _ast.Import):\n for alias in node.names:\n name = alias.asname or alias.name.split(".", 1)[0]\n self._add(scope, name, node.lineno, node.col_offset, "import", alias.name)\n return\n if isinstance(node, _ast.ImportFrom):\n for alias in node.names:\n name = alias.asname or alias.name\n self._add(scope, name, node.lineno, node.col_offset, "importfrom", node.module)\n return\n if isinstance(node, _ast.NamedExpr):\n # Walrus :=\n if isinstance(node.target, _ast.Name):\n self._add(scope, node.target.id, node.target.lineno, node.target.col_offset, "walrus")\n self._walk(node.value, scope)\n return\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n # No binding here, but an assignment further down binds the\n # outer name rather than a local one. Recorded so the shadowing\n # and reassignment checks do not report it twice over.\n scope.declared_elsewhere.update(node.names)\n return\n # A `case` pattern\'s captures, and `except ... as e`, bind a name -\n # one of the alternatives, of which only one runs. Not `case Boa:`\n # for a class `Boa`: that is the class with its brackets left off,\n # which the compiler\'s own error, or the match it then fits, says.\n if (\n isinstance(node, (_ast.MatchAs, _ast.MatchStar))\n and node.name is not None\n and not (isinstance(node, _ast.MatchAs) and node.pattern is None and node.name in self._classes)\n ):\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.MatchMapping) and node.rest is not None:\n self._add(scope, node.rest, node.lineno, node.col_offset, "capture")\n if isinstance(node, _ast.ExceptHandler) and node.name is not None:\n self._add(scope, node.name, node.lineno, node.col_offset, "capture")\n\n # --- Scope-introducing nodes -------------------------------------\n if isinstance(node, _PLL_SCOPE_FUNC):\n # Function name binds in the OUTER scope.\n kind = "accessor" if _pll_redefines_on_purpose(node) else "functiondef"\n self._add(scope, node.name, node.lineno, node.col_offset, kind)\n for d in node.decorator_list:\n self._walk(d, scope)\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "function", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _ast.Lambda):\n for d in node.args.defaults:\n self._walk(d, scope)\n for d in (node.args.kw_defaults or []):\n if d is not None:\n self._walk(d, scope)\n inner = _PllScope(node, "lambda", scope)\n self.scopes.append(inner)\n for name, lineno, col in _pll_arg_names(node.args):\n self._add(inner, name, lineno, col, "argument")\n self._walk(node.body, inner)\n return\n if isinstance(node, _ast.ClassDef):\n self._add(scope, node.name, node.lineno, node.col_offset, "classdef")\n for d in node.decorator_list:\n self._walk(d, scope)\n for b in node.bases:\n self._walk(b, scope)\n for kw in node.keywords:\n self._walk(kw.value, scope)\n inner = _PllScope(node, "class", scope)\n self.scopes.append(inner)\n for s in node.body:\n self._walk(s, inner)\n return\n if isinstance(node, _PLL_SCOPE_COMP):\n inner = _PllScope(node, "comprehension", scope)\n self.scopes.append(inner)\n # Per Python semantics: outermost iter is evaluated in the OUTER\n # scope, everything else inside the comp scope.\n for i, gen in enumerate(node.generators):\n if i == 0:\n self._walk(gen.iter, scope)\n else:\n self._walk(gen.iter, inner)\n self._add_target(gen.target, inner, "comprehension")\n for cond in gen.ifs:\n self._walk(cond, inner)\n if isinstance(node, _ast.DictComp):\n self._walk(node.key, inner)\n self._walk(node.value, inner)\n else:\n self._walk(node.elt, inner)\n return\n\n # --- Default: descend in the same scope --------------------------\n for child in _ast.iter_child_nodes(node):\n self._walk(child, scope)\n\n\n#: The kinds that are said but do not stop the code running: each can be\n#: right as written. Every other kind is an error, which does.\n_PLL_WARNING_KINDS = frozenset(("method-not-called", "test-not-named", "pattern-never-matches"))\n\n\ndef _pll_finding(kind, error_type, line, column, name_token=None, **extras):\n """One static finding, in the shape `_pll_word_static` words.\n\n `extras` are what this kind\'s explanation needs, and only that: the\n checks that walk scopes add `scope_kind`, because only they know it.\n """\n finding = {\n "id": kind,\n "error_type": error_type,\n "severity": "warning" if kind in _PLL_WARNING_KINDS else "error",\n "line_number": line,\n "column": column,\n "name_token": name_token,\n }\n finding.update(extras)\n return finding\n\n\n#: Annotations students write that are not types, and the type each should\n#: be: `table` is the function that makes a table, `Table` the type. Written\n#: out rather than derived, so a name is only reported when its replacement\n#: is certainly right, and is a name a student\'s code has. A row is a\n#: `dict`, the type the course gives for one; PLL\'s `Row` subclasses it.\n_PLL_NOT_A_TYPE = {\n "table": "Table",\n "reactor": "Reactor",\n "row": "dict",\n "string": "str",\n "integer": "int",\n "boolean": "bool",\n "number": "float",\n "Float": "float",\n "Int": "int",\n "Str": "str",\n "Bool": "bool",\n "image": "Image",\n "Number": "float",\n "String": "str",\n "Boolean": "bool",\n "Integer": "int",\n}\n\n#: Methods that are nearly always meant to be called. `movies["rating"].mean`\n#: prints `<bound method Series.mean of ...>` and says nothing; `.mean()` is\n#: what was wanted. Only names that are a method everywhere a student meets\n#: them: `columns`, `values` and `shape` are properties in pandas, and\n#: `width` and `height` are properties on an image, so they are not here.\n_PLL_CALLED_METHODS = frozenset(\n (\n "mean",\n "sum",\n "count",\n "length",\n "rows",\n "head",\n "tail",\n "to_pandas",\n "to_svg",\n "upper",\n "lower",\n "strip",\n "split",\n "keys",\n "items",\n "sort_values",\n "value_counts",\n "describe",\n "median",\n "std",\n "var",\n "nunique",\n "interact",\n )\n)\n\n\n#: Functions whose whole job is to be given a function, where naming a\n#: method without calling it is exactly right.\n_PLL_TAKES_A_FUNCTION = frozenset(\n ("sorted", "map", "filter", "min", "max", "sort", "reduce", "any", "all")\n)\n\n#: Types a student might assign to a field name by mistake: `year = int`\n#: rather than `year: int`.\n_PLL_TYPE_NAMES = frozenset(("int", "float", "str", "bool", "list", "dict", "tuple"))\n\n#: Built-in types whose methods are passed as functions: `str.upper` is the\n#: function every string\'s `.upper()` calls, and can never be one forgotten.\n_PLL_METHOD_TYPES = frozenset(("str", "int", "float", "list", "dict", "set", "tuple", "bytes", "frozenset"))\n\n\ndef _pll_union_written(annotation, aliases, seen=()):\n """The classes a union annotation names, read from the code: `A | B`,\n `Union[A, B]`, `Optional[A]`, a name the file sets to one of those, or\n any of them in quotes - or None for one that cannot be read so."""\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.Constant) and annotation.value is None:\n return ["None"]\n if isinstance(annotation, _ast.Name):\n if annotation.id in aliases and annotation.id not in seen:\n return _pll_union_written(aliases[annotation.id], aliases, seen + (annotation.id,))\n return [annotation.id]\n if isinstance(annotation, _ast.BinOp) and isinstance(annotation.op, _ast.BitOr):\n parts = [annotation.left, annotation.right]\n elif isinstance(annotation, _ast.Subscript) and _pll_dotted(annotation.value) in (\n "Union", "typing.Union", "Optional", "typing.Optional"\n ):\n parts = list(annotation.slice.elts) if isinstance(annotation.slice, _ast.Tuple) else [annotation.slice]\n if _pll_dotted(annotation.value).endswith("Optional"):\n parts.append(_ast.Constant(value=None))\n else:\n return None\n members = []\n for part in parts:\n named = _pll_union_written(part, aliases, seen)\n if named is None:\n return None\n members.extend(m for m in named if m not in members)\n return members\n\n\ndef _pll_names_type(annotation):\n """Whether an annotation is `type` or `type[...]`."""\n if isinstance(annotation, _ast.Subscript):\n annotation = annotation.value\n return isinstance(annotation, _ast.Name) and annotation.id == "type"\n\n\nclass _PllSilenceVisitor(_ast.NodeVisitor):\n """Collect mistakes that run without a word being said.\n\n Each of these is valid Python that does nothing, or does something\n other than what was meant, and so produces no error at all:\n\n - a value computed and thrown away inside a function, which is how a\n test written without `assert` always passes;\n - `assert(x, 1)`, where the tuple is always true;\n - a function containing `assert` that is not named `test_...` and is\n never called, so it never runs;\n - an annotation naming a function rather than a type, which turns\n every check on that value off;\n - a method named but not called, which yields the method object;\n - a dataclass field with no type, or written `year = int`, which\n makes no field and goes wrong somewhere else entirely;\n - a class with fields and no `@dataclass`, whose constructor then\n "takes no arguments";\n - `a == Boa`, which is always False;\n - a `case` pattern naming a class nothing defines, which fails only\n when that case is tried, or one the subject\'s annotation never\n allows, which never matches.\n\n Only inside a function for the first one: at the top level a bare\n expression is displayed, and the course relies on that.\n """\n\n __slots__ = (\n "found",\n "_code",\n "_defined",\n "_depth",\n "_called",\n "_asserting",\n "_classes",\n "_fields",\n "_type_valued",\n "_known",\n "_aliases",\n "_bases",\n "_functions",\n )\n\n def __init__(self, code, defined, known=frozenset()):\n self.found = []\n # The student\'s text, so advice can quote `return order_amt + 4`\n # rather than say "`return` it".\n self._code = code\n # Every name the file (or the session) binds at the top level: an\n # alias like `Number = int | float` is a type an annotation can name.\n self._defined = defined\n self._depth = 0\n # For each function being visited, its parameters annotated `type`,\n # whose values are classes and can be compared with one.\n self._type_valued = []\n # Classes defined in this file, so `== Boa` can be told from `== b`.\n self._classes = set()\n # Their field names. A field called `count` or `items` happens to\n # share its name with a method, and `s.count` is then exactly\n # right - so those are not "a method you forgot to call".\n self._fields = set()\n # Names used anywhere other than as the function\'s own definition,\n # so a helper that is never called can be told from one that is.\n self._called = set()\n # A finding for each function that contains an `assert` and is not\n # a test - reported only if nothing calls it, which is known at the end.\n self._asserting = []\n # Every name something can find: bound anywhere in the file, a\n # built-in, or one of the libraries\'. A `case` pattern\'s class\n # outside it is a NameError whenever that case is tried.\n self._known = known\n # The top-level names set to a type, by what they are set to:\n # `Animal = Boa | Armadillo`, for the classes a `match` can meet.\n self._aliases = {}\n # Each class\'s bases, by name.\n self._bases = {}\n # The functions being visited, innermost last.\n self._functions = []\n\n # ---- functions ----\n\n def scan(self, tree):\n """The findings for `tree`, with the classes and their fields collected first.\n\n `a == Boa` is only recognisable as a comparison against a class if\n `Boa` is already known, and a function written above the class it\n uses is perfectly ordinary. The field names are wanted for the same\n reason, by the checks that would otherwise mistake one for a method.\n """\n for node in _ast.walk(tree):\n if not isinstance(node, _ast.ClassDef):\n continue\n self._classes.add(node.name)\n self._bases[node.name] = [_pll_dotted(base) for base in node.bases]\n for stmt in node.body:\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name):\n self._fields.add(stmt.target.id)\n for stmt in tree.body:\n if isinstance(stmt, _ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], _ast.Name):\n self._aliases[stmt.targets[0].id] = stmt.value\n elif isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name) and stmt.value is not None:\n self._aliases[stmt.target.id] = stmt.value\n elif isinstance(stmt, getattr(_ast, "TypeAlias", ())) and isinstance(stmt.name, _ast.Name):\n self._aliases[stmt.name.id] = stmt.value\n self.visit(tree)\n self.found.extend(f for f in self._asserting if f["name_token"] not in self._called)\n return self.found\n\n def _function(self, node):\n self._functions.append(node)\n self._depth += 1\n self._type_valued.append({\n arg.arg\n for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs\n if _pll_names_type(arg.annotation)\n })\n self.generic_visit(node)\n self._type_valued.pop()\n self._depth -= 1\n self._functions.pop()\n if self._contains_assert(node) and not node.name.startswith("test_"):\n self._asserting.append(\n _pll_finding("test-not-named", "NeverRun", node.lineno, node.col_offset, node.name)\n )\n self._not_a_type(node.returns)\n\n visit_FunctionDef = _function\n visit_AsyncFunctionDef = _function\n\n @staticmethod\n def _contains_assert(node):\n for child in _ast.walk(node):\n if isinstance(child, _ast.Assert):\n return True\n return False\n\n def visit_Name(self, node):\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.id)\n self.generic_visit(node)\n\n def visit_Attribute(self, node):\n # A method is called through its object: `a.check()`.\n if isinstance(node.ctx, _ast.Load):\n self._called.add(node.attr)\n self.generic_visit(node)\n\n # ---- classes and their fields ----\n\n def visit_ClassDef(self, node):\n self._classes.add(node.name)\n decorated = any(\n (isinstance(d, _ast.Name) and d.id == "dataclass")\n or (isinstance(d, _ast.Attribute) and d.attr == "dataclass")\n or (\n isinstance(d, _ast.Call)\n and (\n (isinstance(d.func, _ast.Name) and d.func.id == "dataclass")\n or (isinstance(d.func, _ast.Attribute) and d.func.attr == "dataclass")\n )\n )\n for d in node.decorator_list\n )\n annotated = [\n stmt\n for stmt in node.body\n if isinstance(stmt, _ast.AnnAssign) and isinstance(stmt.target, _ast.Name)\n ]\n for stmt in node.body:\n # `year` on a line of its own: meant as a field, but it is a\n # use of a name, so it fails as a NameError somewhere else.\n if isinstance(stmt, _ast.Expr) and isinstance(stmt.value, _ast.Name):\n self.found.append(\n _pll_finding(\n "field-no-type", "FieldNeedsType", stmt.lineno, stmt.col_offset, stmt.value.id\n )\n )\n # `year = int`: a class attribute holding a type, which makes\n # no field at all and goes wrong much later, in the argument\n # count of a constructor nobody wrote.\n if (\n isinstance(stmt, _ast.Assign)\n and len(stmt.targets) == 1\n and isinstance(stmt.targets[0], _ast.Name)\n and isinstance(stmt.value, _ast.Name)\n and stmt.value.id in _PLL_TYPE_NAMES\n ):\n self.found.append(\n _pll_finding(\n "field-assigned-type",\n "FieldNeedsType",\n stmt.lineno,\n stmt.col_offset,\n stmt.targets[0].id,\n # The type they wrote, so the fix quotes it back exactly.\n written_type=stmt.value.id,\n )\n )\n # Annotated fields and no `@dataclass`: `X(...)` then fails with\n # "takes no arguments", which says nothing about the decorator. Only\n # for a class with no base: a `NamedTuple` declares its fields this\n # way, and a subclass is made by its base\'s constructor.\n writes_init = any(\n isinstance(stmt, _PLL_SCOPE_FUNC) and stmt.name == "__init__"\n for stmt in node.body\n )\n has_base = any(not (isinstance(b, _ast.Name) and b.id == "object") for b in node.bases)\n if annotated and not decorated and not writes_init and not has_base:\n self.found.append(\n _pll_finding("class-needs-dataclass", "NotADataclass", node.lineno, node.col_offset, node.name)\n )\n self.generic_visit(node)\n\n # ---- `match` ----\n\n def visit_Match(self, node):\n """Each `case` class pattern\'s class: one nothing defines, and one\n the subject can never be.\n\n `case Aardvark(b):` fails only when it is tried, so a program whose\n values always match an earlier `case` hides it; and a class the\n subject\'s annotation does not allow is a `case` that never matches.\n """\n members, union = self._variants(node.subject)\n facts = {\n "subject": _ast.get_source_segment(self._code, node.subject),\n "union": union,\n "members": members,\n "classes": sorted(self._classes),\n }\n for case in node.cases:\n for pattern in _ast.walk(case.pattern):\n if not (isinstance(pattern, _ast.MatchClass) and isinstance(pattern.cls, _ast.Name)):\n continue\n cls = pattern.cls\n if cls.id not in self._known and "*" not in self._known:\n self.found.append(\n _pll_finding("unknown-pattern-class", "NameError", cls.lineno, cls.col_offset, cls.id, **facts)\n )\n elif members and cls.id not in members and self._never_matches(cls.id, members):\n self.found.append(\n _pll_finding("pattern-never-matches", "NeverMatches", cls.lineno, cls.col_offset, cls.id, **facts)\n )\n self.generic_visit(node)\n\n def _variants(self, subject):\n """The classes `subject` can be, and the name of their union - when\n it is a parameter annotated with a union this file writes out."""\n if not (isinstance(subject, _ast.Name) and self._functions):\n return None, None\n args = self._functions[-1].args\n annotation = next(\n (a.annotation for a in args.posonlyargs + args.args + args.kwonlyargs if a.arg == subject.id), None\n )\n members = _pll_union_written(annotation, self._aliases) if annotation is not None else None\n if not members or len(members) < 2:\n return None, None\n written = annotation.value.strip() if isinstance(annotation, _ast.Constant) else _pll_dotted(annotation)\n return members, written if written in self._aliases else None\n\n def _never_matches(self, name, members):\n """Whether a value that is one of `members` can never be a `name`:\n both are this file\'s classes, and `name` is no member\'s base."""\n if name not in self._classes or not all(m in self._classes or m == "None" for m in members):\n return False\n pending = [m for m in members if m != "None"]\n seen = set()\n while pending:\n member = pending.pop()\n if member == name:\n return False\n if member in seen:\n continue\n seen.add(member)\n pending.extend(b for b in self._bases.get(member, []) if b is not None)\n return True\n\n def _class_valued(self, side):\n """Whether `side` is a class itself, which a class can equal:\n `type(a)`, `a.__class__`, another class, or a parameter annotated\n `type`."""\n if isinstance(side, _ast.Call) and isinstance(side.func, _ast.Name) and side.func.id == "type":\n return True\n if isinstance(side, _ast.Attribute) and side.attr == "__class__":\n return True\n return isinstance(side, _ast.Name) and (\n side.id in self._classes or any(side.id in names for names in self._type_valued)\n )\n\n def visit_Compare(self, node):\n # `if a == Boa:` is always False - a value is never equal to the\n # class it was made from. `type(a) == Boa`, though, is a real check.\n sides = [node.left] + list(node.comparators)\n # `x is 60.5`, `name is "Ada"`: `is` asks whether two are the same\n # object, which a number or a string written out need not be -\n # Python\'s SyntaxWarning, as a finding of the level\'s.\n for op, left, right in zip(node.ops, sides, sides[1:]):\n if isinstance(op, (_ast.Is, _ast.IsNot)):\n literal = next(\n (\n side\n for side in (left, right)\n if isinstance(side, _ast.Constant)\n and side.value is not None\n and not isinstance(side.value, bool)\n and side.value is not Ellipsis\n ),\n None,\n )\n if literal is not None:\n self.found.append(\n _pll_finding(\n "is-literal",\n "WrongComparison",\n node.lineno,\n node.col_offset,\n operator="is not" if isinstance(op, _ast.IsNot) else "is",\n literal=_ast.get_source_segment(self._code, literal),\n )\n )\n if any(isinstance(op, (_ast.Eq, _ast.NotEq)) for op in node.ops):\n for i, side in enumerate(sides):\n others = sides[:i] + sides[i + 1 :]\n if (\n isinstance(side, _ast.Name)\n and side.id in self._classes\n and not any(self._class_valued(other) for other in others)\n ):\n self.found.append(\n _pll_finding("compared-with-class", "AlwaysFalse", side.lineno, side.col_offset, side.id)\n )\n self.generic_visit(node)\n\n # ---- statements whose value goes nowhere ----\n\n def visit_Expr(self, node):\n value = node.value\n # A method named but not called is the more specific thing to say\n # about `t.mean` on a line of its own, so it wins.\n if not self._method_not_called(value) and self._depth > 0 and self._discarded(value):\n self.found.append(\n _pll_finding(\n "unused-comparison" if isinstance(value, _ast.Compare) else "unused-value",\n "UnusedValue",\n node.lineno,\n node.col_offset,\n expression=_ast.get_source_segment(self._code, value),\n )\n )\n self.generic_visit(node)\n\n @staticmethod\n def _discarded(value):\n """Whether this expression statement can only be a mistake.\n\n A call, an await or a yield is there for its effect. A string is a\n docstring. `...` is a placeholder. Everything else computes\n something and drops it.\n """\n if isinstance(\n value,\n (\n _ast.Call,\n _ast.Await,\n _ast.Yield,\n _ast.YieldFrom,\n _ast.NamedExpr,\n ),\n ):\n return False\n if isinstance(value, _ast.Constant):\n return False\n return True\n\n def _method_not_called(self, value):\n """`movies["rating"].mean` - the method itself, not its result."""\n if (\n isinstance(value, _ast.Attribute)\n and value.attr in _PLL_CALLED_METHODS\n and value.attr not in self._fields\n # `str.upper`: read off a type, so the function, on purpose.\n and not (\n isinstance(value.value, _ast.Name)\n and (value.value.id in _PLL_METHOD_TYPES or value.value.id in self._classes)\n )\n ):\n self.found.append(\n _pll_finding("method-not-called", "NotCalled", value.lineno, value.col_offset, value.attr)\n )\n return True\n return False\n\n def visit_Call(self, node):\n # A method named but not called, wherever its value is used:\n # `print(movies["rating"].mean)`.\n #\n # Not for a function that takes a function - `sorted(xs,\n # key=str.lower)` passes `str.lower` deliberately, and that is the\n # one shape where naming a method without calling it is right.\n callee = node.func\n name = callee.id if isinstance(callee, _ast.Name) else None\n if name in _PLL_TAKES_A_FUNCTION:\n self.generic_visit(node)\n return\n for keyword in node.keywords:\n if keyword.arg not in ("key", "default_factory"):\n self._method_not_called(keyword.value)\n for argument in node.args:\n self._method_not_called(argument)\n self.generic_visit(node)\n\n # ---- asserts ----\n\n def visit_Assert(self, node):\n if isinstance(node.test, _ast.Tuple) and node.test.elts:\n self.found.append(_pll_finding("assert-tuple", "AlwaysTrue", node.lineno, node.col_offset))\n self.generic_visit(node)\n\n # ---- annotations ----\n\n def _not_a_type(self, annotation):\n if (\n isinstance(annotation, _ast.Name)\n and annotation.id in _PLL_NOT_A_TYPE\n # A class or alias of their own called `Number` is a type, and\n # naming it in an annotation is right.\n and annotation.id not in self._classes\n and annotation.id not in self._defined\n ):\n self.found.append(\n _pll_finding(\n "annotation-not-a-type", "NotAType", annotation.lineno, annotation.col_offset, annotation.id\n )\n )\n\n def _annotation(self, node):\n self._not_a_type(getattr(node, "annotation", None))\n self.generic_visit(node)\n\n visit_AnnAssign = _annotation\n visit_arg = _annotation\n\n\ndef _pll_session_bound_names(session_key):\n """User-defined names already bound in a session (for REPL checks).\n\n Baseline names from `_pll_initial_globals` (image primitives, etc.) are\n skipped unless the user rebound them, so prompt analysis matches file\n analysis for the same snippet.\n """\n module = _pll_sessions.get(session_key)\n if module is None:\n return []\n g = module.__dict__\n names = []\n for n in g:\n if n.startswith("_") or n in ("__builtins__", "__name__", "__doc__"):\n continue\n if n in _pll_initial_globals and g[n] is _pll_initial_globals[n]:\n continue\n names.append(n)\n return names\n\n\ndef _pll_predefined_library_names():\n """Public names every session starts with, mapped to their library.\n\n `install.py` seeds `_pll_initial_globals` with the image / table\n / reactor exports, so those names (`circle`, `rectangle`, `table`,\n `animate`, ...) are bound before the student writes anything. A student\n definition that reuses one is shadowing, exactly like a built-in - just\n with wording that says where the name comes from. The label is derived\n from the registered `pll` package when it is loaded, and falls back to\n the generic "library" otherwise (e.g. bootstrap-only test harnesses).\n """\n public = {n for n in _pll_initial_globals if not n.startswith("_")}\n labels = {}\n pll = _sys.modules.get("pll")\n if pll is not None:\n for lib in ("image", "table", "reactor"):\n mod = getattr(pll, lib, None)\n if mod is None:\n continue\n for n in public:\n if hasattr(mod, n):\n labels[n] = lib\n return {n: labels.get(n, "library") for n in public}\n\n\ndef _pll_is_pll_import_loc(loc):\n """True when a binding loc tuple imports from a `pll` module.\n\n `from pll.image import circle` re-binds the library\'s own value, so it\n shadows nothing and must not be flagged.\n """\n kind, module = loc[2], loc[3]\n return (\n kind in ("import", "importfrom")\n and isinstance(module, str)\n and (module == "pll" or module.startswith("pll."))\n )\n\n\ndef _pll_refuses_reassignment(level, scope_kind):\n """Whether `level` refuses a name assigned twice in a scope of `scope_kind`.\n\n Everywhere at beginner; at intermediate only at module scope, so that a\n function can keep a running total (`total = 0; for x in xs: total += x`).\n The wording asks it too, to offer only fixes the level accepts.\n """\n if level == _PLL_LEVEL_BEGINNER:\n return True\n return level == _PLL_LEVEL_INTERMEDIATE and scope_kind == "module"\n\n\ndef _pll_static_analyze(code, level, filename, session_key=None):\n """Run static checks for `level` over `code` and return findings.\n\n Returns a list of dicts made by `_pll_finding`: `id`, `error_type`, a\n position and `name_token`, and whatever that kind\'s explanation needs.\n\n If `session_key` is set, names already bound in that session are treated\n as existing module-level bindings. That way a `#level beginner` prompt cannot\n reassign a name the file (or an earlier prompt line) already defined.\n """\n if level not in _PLL_TEACHING_LEVELS:\n return []\n try:\n with _pll_without_syntax_warnings():\n tree = _ast.parse(code, filename=filename)\n except SyntaxError:\n # Let the runtime path surface SyntaxErrors with their normal flow.\n return []\n\n findings = []\n builder = _PllScopeBuilder()\n builder.build(tree)\n if session_key:\n module = builder.scopes[0]\n for name in _pll_session_bound_names(session_key):\n locs = module.bindings.setdefault(name, [])\n locs.insert(0, (0, 0, "preexisting", None))\n builtins_set = set(dir(_builtins_mod)) - _PLL_BUILTINS_MODULE_META\n library_names = _pll_predefined_library_names()\n\n for scope in builder.scopes:\n # Names visible from any enclosing scope, paired with the *nearest*\n # outer binding\'s location and scope kind. We walk parents inner-to-\n # outer and refuse to overwrite, so the closest enclosing binding wins\n # (which is the one Python\'s lookup rules would resolve to).\n enclosing = {} # name -> (lineno, col, scope_kind, binding kind)\n cur = scope.parent\n while cur is not None:\n for outer_name, outer_locs in cur.bindings.items():\n if outer_name in enclosing:\n continue\n outer_first = outer_locs[0]\n enclosing[outer_name] = (outer_first[0], outer_first[1], cur.kind, outer_first[2])\n cur = cur.parent\n\n shadowed_in_scope = set()\n\n # ---- Shadowing first ----\n for name, locs in scope.bindings.items():\n if name in scope.declared_elsewhere:\n # `global x` has its own finding; one for the same name here\n # would read as a second, separate mistake.\n continue\n if scope.kind == "class":\n # A name bound in a class body is an *attribute*, not a\n # variable. `id: int` in a dataclass declares a field, and\n # `id` everywhere else still finds the built-in, so nothing\n # is shadowed. The class\'s own name is bound in the\n # enclosing scope and checked there, so `class list:` is\n # still caught.\n continue\n first_loc = locs[0]\n # A binding imported from a `pll` module re-binds the library\'s\n # own value and shadows nothing; a "preexisting" marker records\n # a name the session already bound (REPL analysis) rather than\n # one this code defines. Report at the first binding that\n # actually (re)defines the name; when there is none, this name\n # has nothing to report here.\n defining_loc = None\n for loc in locs:\n if not _pll_is_pll_import_loc(loc) and loc[2] != "preexisting":\n defining_loc = loc\n break\n report_loc = defining_loc if defining_loc is not None else first_loc\n if name in enclosing:\n shadowed_in_scope.add(name)\n outer = enclosing[name]\n findings.append(_pll_finding(\n "shadowing", "Shadowing", report_loc[0], report_loc[1], name,\n scope_kind=scope.kind,\n binding=report_loc[2],\n outer_line_number=outer[0],\n outer_column=outer[1],\n outer_scope_kind=outer[2],\n outer_binding=outer[3],\n ))\n elif name in builtins_set and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append(_pll_finding(\n "shadowing-builtin", "Shadowing", defining_loc[0], defining_loc[1], name,\n scope_kind=scope.kind,\n ))\n elif name in library_names and defining_loc is not None:\n shadowed_in_scope.add(name)\n findings.append(_pll_finding(\n "shadowing-library", "Shadowing", defining_loc[0], defining_loc[1], name,\n scope_kind=scope.kind,\n library=library_names[name],\n binding=defining_loc[2],\n module=defining_loc[3],\n ))\n\n # ---- Then reassignment (skip names already shadow-flagged) ----\n if _pll_refuses_reassignment(level, scope.kind):\n for name, locs in scope.bindings.items():\n if name in shadowed_in_scope or name in scope.declared_elsewhere:\n continue\n # A property\'s setter is the property, not a second one.\n locs = [loc for loc in locs if loc[2] != "accessor"]\n # The same capture in two `case`s (or two `except`s) is bound\n # by whichever one runs, never twice.\n if all(loc[2] == "capture" for loc in locs):\n continue\n if len(locs) > 1:\n second_loc = locs[1]\n first_loc = locs[0]\n # A `def` written twice is not an accumulator: the\n # advice for a reassigned variable (running totals, use\n # `sum`) is about something else entirely, and the fix\n # is to rename one of them.\n definitions = {"functiondef": "function", "classdef": "class"}\n both = definitions.get(first_loc[2]) if first_loc[2] == second_loc[2] else None\n if both is not None:\n findings.append(_pll_finding(\n "duplicate-definition", "DuplicateDefinition", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n definition_kind=both,\n ))\n continue\n findings.append(_pll_finding(\n "reassignment", "Reassignment", second_loc[0], second_loc[1], name,\n scope_kind=scope.kind,\n first_line_number=first_loc[0],\n first_column=first_loc[1],\n ))\n\n # ---- `global` / `nonlocal` ----\n # Both are disallowed at beginner and intermediate. One finding per\n # statement, not per name, so `global x, y` is one diagnostic on its line.\n for node in _ast.walk(tree):\n if isinstance(node, (_ast.Global, _ast.Nonlocal)):\n findings.append(_pll_finding(\n "disallowed-keyword", "DisallowedKeyword", node.lineno, node.col_offset, node.names[0],\n keyword="global" if isinstance(node, _ast.Global) else "nonlocal",\n names=list(node.names),\n ))\n\n # ---- mistakes that would otherwise run without a word ----\n known = set().union(*(scope.bindings for scope in builder.scopes)) | builtins_set | set(library_names)\n findings.extend(_PllSilenceVisitor(code, set(builder.scopes[0].bindings), known).scan(tree))\n\n findings.sort(key=lambda f: (f["line_number"] or 0, f["column"] or 0))\n return findings\n';
|
|
207
207
|
|
|
208
208
|
// src/python/bootstrap/imports.py
|
|
209
209
|
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';
|
|
@@ -212,16 +212,16 @@ var imports_default = '# The student\'s own files, imported as they run: each he
|
|
|
212
212
|
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';
|
|
213
213
|
|
|
214
214
|
// src/python/explain/names.py
|
|
215
|
-
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 headline, how_to_fix = _pll_syntax_wording(e.type, e.message, e.text)\n return {"id": "syntax-error", "headline": headline, "howToFix": how_to_fix, "nameToken": None}\n\n\ndef _pll_syntax_wording(error_type, message, line):\n """`(headline, how_to_fix)` for a syntax error on `line`: the first\n rewording that fits."""\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 # `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@_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 # "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 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 = _pll_defined_later_at(e.source, name, e.info["lineNumber"]) if named else None\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 = \'%s: "%s"\' % (annotated, name) if annotated else \'-> "%s"\' % 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 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 # 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 if named:\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_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_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';
|
|
215
|
+
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 headline, how_to_fix = _pll_syntax_wording(e.type, e.message, e.text)\n return {"id": "syntax-error", "headline": headline, "howToFix": how_to_fix, "nameToken": None}\n\n\ndef _pll_syntax_wording(error_type, message, line):\n """`(headline, how_to_fix)` for a syntax error on `line`: the first\n rewording that fits."""\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 # `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@_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 # "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 = _pll_defined_later_at(e.source, name, e.info["lineNumber"]) if named else None\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 = \'%s: "%s"\' % (annotated, name) if annotated else \'-> "%s"\' % 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 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 # 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 if named:\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_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_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';
|
|
216
216
|
|
|
217
217
|
// src/python/explain/typeCheck.py
|
|
218
218
|
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';
|
|
219
219
|
|
|
220
220
|
// src/python/explain/stock.py
|
|
221
|
-
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 quoted = ["`%s`" % name for name in names]\n if len(quoted) <= 1:\n return "".join(quoted)\n return "%s and %s" % (", ".join(quoted[:-1]), quoted[-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 return "`%s` is a test, so it cannot take any parameters." % name, [\n "Write `def %s():` with nothing in the brackets, and make the values it needs inside it." % name\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_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@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not iterable")\ndef _pll_not_iterable(m, e):\n """`\'int\' object is not iterable`, almost always `for x in len(xs)`."""\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 name = _pll_last_segment(m.group(1))\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';
|
|
221
|
+
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 return "`%s` is a test, so it cannot take any parameters." % name, [\n "Write `def %s():` with nothing in the brackets, and make the values it needs inside it." % name\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_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@_pll_stock("TypeError", r"^\'([\\w.]+)\' object is not iterable")\ndef _pll_not_iterable(m, e):\n """`\'int\' object is not iterable`, almost always `for x in len(xs)`."""\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 name = _pll_last_segment(m.group(1))\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';
|
|
222
222
|
|
|
223
223
|
// src/python/explain/static.py
|
|
224
|
-
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}\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, "parameter" if inner == "parameter" 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 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 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 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 (or two `class`es) 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 = "classes" if f.get("definition_kind") == "class" else "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 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# 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`: a test written without `assert`."""\n # Their own comparison, not `assert ...`.\n shown = "`assert %s`" % f["expression"] if f.get("expression") else "`assert ...`"\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 "At the top level a line like this is displayed; inside a function nothing shows it.",\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 # `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 else:\n fixes = ["Did you mean `return %s`? On its own, the value is worked out and thrown away." % expression]\n return "This line works out a value and then throws it away.", fixes + [\n "Nothing in Python changes as a result of this line."\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, [\n "Write `%s` instead." % type_name,\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';
|
|
224
|
+
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}\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, "parameter" if inner == "parameter" 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 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 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 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 (or two `class`es) 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 = "classes" if f.get("definition_kind") == "class" else "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 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# 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`: a test written without `assert`."""\n # Their own comparison, not `assert ...`.\n shown = "`assert %s`" % f["expression"] if f.get("expression") else "`assert ...`"\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 "At the top level a line like this is displayed; inside a function nothing shows it.",\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 # `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 else:\n fixes = ["Did you mean `return %s`? On its own, the value is worked out and thrown away." % expression]\n return "This line works out a value and then throws it away.", fixes + [\n "Nothing in Python changes as a result of this line."\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, [\n "Write `%s` instead." % type_name,\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';
|
|
225
225
|
|
|
226
226
|
// src/python/libraries/image.py
|
|
227
227
|
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';
|