From df95e239218fb1255c7b764063b9edafc5b725d5 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:07:40 +0700 Subject: [PATCH] Add xraywidgets, and the disassembler that goes in the pilot lesson The first of the interactive components issue #14 lists. Type Python, see the bytecode, and turn on the four things dis leaves out: the specialized opcodes the interpreter installed, the inline cache entries sitting between the instructions, the stack depth either side of each one, and the exception table. Every widget renders twice from one piece of code. With nothing installed it draws plain HTML with the real numbers in it and the buttons switched off, which is what a reader gets on GitHub, in an nbconvert render, in a PDF, and while Pyodide is still starting. With anywidget installed it draws the same picture with the buttons working. The front end holds no bytecode logic at all: Python computes the rows and renders the markup, the JavaScript puts it on the page and hands clicks back. So the two pictures cannot disagree, and there is a test that greps the module for opcode names to keep it that way. Two rules are enforced rather than remembered. parts.chip will not build a coloured badge without words in it, so colour is never the only signal, and style.py reads the palette out of pyxray.theme with a test that fails on a hand written hex colour, so the widgets cannot fork away from the diagrams beside them. Also adds pyxray.bytecode.exception_table, which the exception toggle needs and which nothing exposed before, and installs the live extra in the CI test job so the interactive half is exercised on every pull request. --- .github/workflows/ci.yml | 6 +- README.md | 3 +- pyproject.toml | 22 +- pyxray/src/pyxray/bytecode.py | 53 ++++ pyxray/tests/test_bytecode.py | 54 ++++ uv.lock | 91 +++++- xraywidgets/README.md | 73 +++++ xraywidgets/pyproject.toml | 25 ++ xraywidgets/src/xraywidgets/__init__.py | 18 ++ xraywidgets/src/xraywidgets/base.py | 144 +++++++++ xraywidgets/src/xraywidgets/disassembler.py | 296 ++++++++++++++++++ xraywidgets/src/xraywidgets/html.py | 88 ++++++ xraywidgets/src/xraywidgets/parts.py | 102 ++++++ .../src/xraywidgets/static/disassembler.js | 88 ++++++ xraywidgets/src/xraywidgets/strings.py | 57 ++++ xraywidgets/src/xraywidgets/style.py | 167 ++++++++++ xraywidgets/tests/test_xraywidgets_base.py | 75 +++++ .../tests/test_xraywidgets_disassembler.py | 197 ++++++++++++ xraywidgets/tests/test_xraywidgets_html.py | 72 +++++ xraywidgets/tests/test_xraywidgets_live.py | 79 +++++ xraywidgets/tests/test_xraywidgets_parts.py | 80 +++++ xraywidgets/tests/test_xraywidgets_strings.py | 50 +++ xraywidgets/tests/test_xraywidgets_style.py | 52 +++ 23 files changed, 1888 insertions(+), 4 deletions(-) create mode 100644 xraywidgets/README.md create mode 100644 xraywidgets/pyproject.toml create mode 100644 xraywidgets/src/xraywidgets/__init__.py create mode 100644 xraywidgets/src/xraywidgets/base.py create mode 100644 xraywidgets/src/xraywidgets/disassembler.py create mode 100644 xraywidgets/src/xraywidgets/html.py create mode 100644 xraywidgets/src/xraywidgets/parts.py create mode 100644 xraywidgets/src/xraywidgets/static/disassembler.js create mode 100644 xraywidgets/src/xraywidgets/strings.py create mode 100644 xraywidgets/src/xraywidgets/style.py create mode 100644 xraywidgets/tests/test_xraywidgets_base.py create mode 100644 xraywidgets/tests/test_xraywidgets_disassembler.py create mode 100644 xraywidgets/tests/test_xraywidgets_html.py create mode 100644 xraywidgets/tests/test_xraywidgets_live.py create mode 100644 xraywidgets/tests/test_xraywidgets_parts.py create mode 100644 xraywidgets/tests/test_xraywidgets_strings.py create mode 100644 xraywidgets/tests/test_xraywidgets_style.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2502c28..1b60246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,11 @@ jobs: # rather than sit next to it, or both legs of the matrix run the same interpreter # and the job quietly stops testing what it says it tests. - run: uv python pin ${{ matrix.python }} - - run: uv sync --all-packages + # The `live` extra brings anywidget, which is what the widgets use when a reader can + # click on them. It is installed here rather than only in a developer's environment + # because the alternative is that the live half of every widget is never run by CI, + # and the static half is the half that is easy to keep working. + - run: uv sync --all-packages --extra live - run: uv run python -c "import sys; print(sys.version)" - run: uv run pytest diff --git a/README.md b/README.md index 5bd1f43..01b32b8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Pinned to `v3.15.0rc1` today and moving to `v3.15.0` when it ships on 1 October | `nbdiagram` | Every picture in a lesson is an Excalidraw scene drawn from Python, written out as an editable `.excalidraw` and as the `.svg` GitHub and Colab display. Colours, type and spacing come from one shared theme, so the diagrams, the charts and the animations look like one project | [tools/nbdiagram](tools/nbdiagram) | | `bpcheck` | The shape a blueprint has to have before somebody can implement from it: the nine sections in order, the header block, the invariant numbering, and no fact deferred to a lesson | [tools/bpcheck](tools/bpcheck) | | `xraymanim` | The animations, and the fifteen shapes they are allowed to be made of. Each one is planned as a storyboard that is checked in milliseconds, so a mistake is caught before anybody pays for a render | [xraymanim](xraymanim) | +| `xraywidgets` | The parts of a lesson you can click, starting with a disassembler that shows what `dis` hides. Each one renders twice from one piece of code: plain HTML with nothing installed, and the same picture with working buttons when anywidget is there | [xraywidgets](xraywidgets) | ## The lessons @@ -124,7 +125,7 @@ blueprints/ the normative specification, mechanical sections generated anim/ manim scenes, built from one shared mobject library apps/ the Gradio playgrounds pyxray/ the instrumentation toolkit every lesson imports -xraywidgets/ anywidget components, one implementation for marimo and Jupyter +xraywidgets/ the widgets a reader clicks, which still draw with nothing installed xraymanim/ the visual grammar, so a hundred animations look like one project conformance/ the differential harness, the golden corpora and the scorecard reimpl/go/ the Go reference implementation, which is the specification's test suite diff --git a/pyproject.toml b/pyproject.toml index 84cae33..53524df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyxray", "refcheck", "xraymanim", + "xraywidgets", ] # Installing the renderer. Manim brings cairo, pango, numpy and scipy with it, which is a @@ -21,6 +22,9 @@ dependencies = [ # thing here you have to opt into: `uv sync --extra anim`, or `just build-animations`. [project.optional-dependencies] anim = ["xraymanim[render]"] +# The widgets that respond to a click. Everything in `xraywidgets` renders as plain HTML +# without this, so a reader who skips it still gets the picture, just not the buttons. +live = ["xraywidgets[live]"] [dependency-groups] dev = [ @@ -38,6 +42,7 @@ members = [ "tools/nbdiagram", "tools/refcheck", "xraymanim", + "xraywidgets", ] [tool.uv.sources] @@ -48,6 +53,7 @@ nbdiagram = { workspace = true } pyxray = { workspace = true } refcheck = { workspace = true } xraymanim = { workspace = true } +xraywidgets = { workspace = true } [tool.pytest.ini_options] testpaths = [ @@ -58,6 +64,7 @@ testpaths = [ "tools/nbdiagram/tests", "tools/refcheck/tests", "xraymanim/tests", + "xraywidgets/tests", ] addopts = "-q --strict-markers --strict-config" markers = [ @@ -80,7 +87,16 @@ filterwarnings = [ ] [tool.ruff.lint.isort] -known-first-party = ["bpcheck", "nbbuild", "nbcheck", "nbdiagram", "pyxray", "refcheck", "xraymanim"] +known-first-party = [ + "bpcheck", + "nbbuild", + "nbcheck", + "nbdiagram", + "pyxray", + "refcheck", + "xraymanim", + "xraywidgets", +] [tool.ruff] line-length = 100 @@ -103,6 +119,10 @@ ignore = [ # The glossary is the same thing without the notebook around it: definitions written as # one paragraph per line, so that a sentence is never split across two of them. "pyxray/src/pyxray/glossary.py" = ["E501"] +# Every word a widget shows a reader lives in one dictionary, written one sentence per line. +# Splitting a sentence across two source lines to please a line length rule would leave the +# sentence looking wrapped in the source and identical on screen, which helps nobody. +"xraywidgets/src/xraywidgets/strings.py" = ["E501"] "lessons/**/diagrams.py" = ["E501"] # The lesson notebooks are the one thing here that runs on an interpreter we do not choose. # A reader in Colab is on whatever Google installed, so a version check the linter thinks diff --git a/pyxray/src/pyxray/bytecode.py b/pyxray/src/pyxray/bytecode.py index e71dd7c..b2049f3 100644 --- a/pyxray/src/pyxray/bytecode.py +++ b/pyxray/src/pyxray/bytecode.py @@ -275,3 +275,56 @@ def line_table(target: CodeLike) -> list[tuple[int, int, int | None]]: own lesson; this is the decoded view a reader needs long before then. """ return list(code_of(target).co_lines()) + + +@dataclass(frozen=True) +class Handler: + """One row of the exception table: a protected range and where it lands. + + `start` and `end` are offsets into `co_code`, and the range is half open, so an + instruction at `end` is not covered. `depth` is how deep the value stack is unwound to + before the handler runs, and `lasti` says whether the offset of the instruction that + raised is pushed as well, which a bare `raise` inside the handler needs in order to know + what it is re-raising. + """ + + start: int + end: int + target: int + depth: int + lasti: bool + + def covers(self, offset: int) -> bool: + """Whether an instruction at this offset is inside the protected range.""" + return self.start <= offset < self.end + + +def exception_table(target: CodeLike) -> list[Handler]: + """The exception table, decoded. + + Since 3.11 there are no `SETUP_FINALLY` instructions in the bytecode. A `try` costs + nothing at all until something raises, and what replaced the instructions is this table, + which is consulted only while unwinding. That is the single biggest reason a modern + disassembly looks different from one a reader may have seen in an older book, and it is + invisible unless something prints the table. + + The decoding is `dis._parse_exception_table`, which is private, and the alternative is + decoding the varint format in `co_exceptiontable` by hand. That format deserves the + lesson it gets rather than a helper function nobody reads, and CPython's own account of + it is in `InternalDocs/exception_handling.md`. An interpreter without that private + function gives an empty list rather than raising, because a missing extra view of the + code is not a reason for a lesson to stop. + """ + parse = getattr(dis, "_parse_exception_table", None) + if parse is None: # pragma: no cover - every supported version has it + return [] + return [ + Handler( + start=entry.start, + end=entry.end, + target=entry.target, + depth=entry.depth, + lasti=bool(entry.lasti), + ) + for entry in parse(code_of(target)) + ] diff --git a/pyxray/tests/test_bytecode.py b/pyxray/tests/test_bytecode.py index d3d7265..3b0c145 100644 --- a/pyxray/tests/test_bytecode.py +++ b/pyxray/tests/test_bytecode.py @@ -207,3 +207,57 @@ def test_the_jump_table_writes_the_arithmetic_out(): def test_a_loop_jumps_both_ways(): directions = {jump.backwards for jump in bytecode.jumps(LOOP)} assert directions == {True, False} + + +#: A `try` with both an `except` and a `finally`, which is the smallest piece of Python that +#: produces more than one handler row. The `finally` body is compiled twice, once for the +#: path where nothing went wrong and once for the path where something did. +GUARDED = """ +try: + risky() +except ValueError: + handled() +finally: + always() +""" + + +def test_code_that_cannot_raise_has_no_exception_table(): + assert bytecode.exception_table("x = 1") == [] + + +def test_a_try_produces_handlers(): + assert bytecode.exception_table(GUARDED) + + +def test_the_handlers_are_the_ones_dis_parses(): + parsed = dis._parse_exception_table(bytecode.code_of(GUARDED)) + ours = bytecode.exception_table(GUARDED) + assert [(one.start, one.end, one.target) for one in ours] == [ + (one.start, one.end, one.target) for one in parsed + ] + + +def test_a_handler_covers_the_offsets_between_its_ends(): + first = bytecode.exception_table(GUARDED)[0] + assert first.covers(first.start) + assert first.covers(first.end - 1) + assert not first.covers(first.end) + assert not first.covers(first.start - 1) + + +def test_the_protected_range_holds_the_call_that_can_raise(): + first = bytecode.exception_table(GUARDED)[0] + inside = [item.opname for item in bytecode.disassemble(GUARDED) if first.covers(item.offset)] + assert "CALL" in inside + + +def test_a_handler_target_is_a_real_offset_in_the_code(): + offsets = {item.offset for item in bytecode.disassemble(GUARDED)} + for handler in bytecode.exception_table(GUARDED): + assert handler.target in offsets + + +def test_the_handlers_come_back_in_offset_order(): + starts = [one.start for one in bytecode.exception_table(GUARDED)] + assert starts == sorted(starts) diff --git a/uv.lock b/uv.lock index ffca51b..f48e7e9 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,21 @@ members = [ "pyxray", "refcheck", "xraymanim", + "xraywidgets", +] + +[[package]] +name = "anywidget" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipywidgets" }, + { name = "psygnal" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/31/0491d707c674b34267f55d96d6a7148e55e7b6718a271686232cf295fbe2/anywidget-0.11.0.tar.gz", hash = "sha256:6695fbef9449cf8c27f421b96c5837aa37f909ec1f60cfa33add333e1b70b169", size = 426999, upload-time = "2026-04-27T23:42:09.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/c2/8fec8e8e2eb920cc2280f569144080cd58622a2eda83bfa4c0c354a63264/anywidget-0.11.0-py3-none-any.whl", hash = "sha256:c574d9acc6503ad27b37a9acea48f957a8ba7c9c9876cfcb37898931c098ce9d", size = 317341, upload-time = "2026-04-27T23:42:08.356Z" }, ] [[package]] @@ -304,12 +319,16 @@ dependencies = [ { name = "pyxray" }, { name = "refcheck" }, { name = "xraymanim" }, + { name = "xraywidgets" }, ] [package.optional-dependencies] anim = [ { name = "xraymanim", extra = ["render"] }, ] +live = [ + { name = "xraywidgets", extra = ["live"] }, +] [package.dev-dependencies] dev = [ @@ -328,8 +347,10 @@ requires-dist = [ { name = "refcheck", editable = "tools/refcheck" }, { name = "xraymanim", editable = "xraymanim" }, { name = "xraymanim", extras = ["render"], marker = "extra == 'anim'", editable = "xraymanim" }, + { name = "xraywidgets", editable = "xraywidgets" }, + { name = "xraywidgets", extras = ["live"], marker = "extra == 'live'", editable = "xraywidgets" }, ] -provides-extras = ["anim"] +provides-extras = ["anim", "live"] [package.metadata.requires-dev] dev = [ @@ -462,6 +483,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "ipywidgets" +version = "8.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/7c/6db60eddf38547353b06d57941f5eee22a990640ce30479fd71a810507f2/ipywidgets-8.1.9.tar.gz", hash = "sha256:bcccba38a6ec3253f7a39c943cea5b9ad01999ce071396171adbc51c6a6a8613", size = 117252, upload-time = "2026-08-18T08:54:24.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl", hash = "sha256:f2b8cbcaae10252b809fbe4d7470db75c09b769a32cbf816d20e5ca6d3c5a79d", size = 140101, upload-time = "2026-08-18T08:54:22.339Z" }, +] + [[package]] name = "isosurfaces" version = "0.1.2" @@ -543,6 +580,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "jupyterlab-widgets" +version = "3.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/8b/e739cf9066ad5037a2d4b0a403f06da374fdccb9748221661c8b492d3dbc/jupyterlab_widgets-3.0.17.tar.gz", hash = "sha256:6e61fe21ca8a66039180a5cc52a433e07279d2fee79c8be963e00d55193f17a8", size = 213919, upload-time = "2026-08-18T08:52:17.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0", size = 217292, upload-time = "2026-08-18T08:52:15.47Z" }, +] + [[package]] name = "manim" version = "0.21.0" @@ -952,6 +998,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psygnal" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1499,6 +1559,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] +[[package]] +name = "widgetsnbextension" +version = "4.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/60/bc7a980fc78837d6ef8f5940cca4cadc433364503a4c4d42e2a7a0de3231/widgetsnbextension-4.0.16.tar.gz", hash = "sha256:adeea0ae78f0856ee4945f413299801b82a0a01416303301f39a704282a37b73", size = 1111094, upload-time = "2026-08-18T08:52:55.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl", hash = "sha256:a31a8774885b96fe825462f5d6496166f0c7cae111195b6465c801d230eb5a4e", size = 2225148, upload-time = "2026-08-18T08:52:53.736Z" }, +] + [[package]] name = "xraymanim" version = "0.1.0" @@ -1518,3 +1587,23 @@ requires-dist = [ { name = "pyxray", editable = "pyxray" }, ] provides-extras = ["render"] + +[[package]] +name = "xraywidgets" +version = "0.1.0" +source = { editable = "xraywidgets" } +dependencies = [ + { name = "pyxray" }, +] + +[package.optional-dependencies] +live = [ + { name = "anywidget" }, +] + +[package.metadata] +requires-dist = [ + { name = "anywidget", marker = "extra == 'live'", specifier = ">=0.9" }, + { name = "pyxray", editable = "pyxray" }, +] +provides-extras = ["live"] diff --git a/xraywidgets/README.md b/xraywidgets/README.md new file mode 100644 index 0000000..ac1c0f0 --- /dev/null +++ b/xraywidgets/README.md @@ -0,0 +1,73 @@ +# xraywidgets + +The interactive parts of a lesson. Type some Python, see what the compiler made of it, and turn on the things `dis` leaves out. + +Every widget here renders twice from one piece of code. Without anywidget installed you get plain HTML: a real picture with real numbers in it, just with the buttons switched off. With anywidget installed you get the same picture with the buttons working. That is not a fallback bolted on at the end, it is how the package is built, and there is a section below on why. + +## The disassembler + +```python +from xraywidgets import Disassembler + +Disassembler("total = sum(values)") +``` + +In a notebook that prints the bytecode as a table. Four things are off by default and can be turned on, either by passing them in or by clicking: + +| Toggle | What it adds | +|---|---| +| Specialized opcodes | The rewritten forms the interpreter installs after it has watched an instruction run a few times, so `LOAD_ATTR` shows up as `LOAD_ATTR_INSTANCE_VALUE` once it has settled | +| Inline caches | The cache entries sitting between the instructions. They take up real bytes in the code object, and `dis` hides them by default, which is why offsets appear to skip | +| Stack depth | How tall the value stack is before and after each instruction, which is the fastest way to see why an expression compiles the way it does | +| Exception table | Where a `try` went. Since 3.11 there are no `SETUP_FINALLY` instructions to look at, and the handlers live in a side table instead | + +They are off to start with because a first disassembly with all four on is a wall of numbers, and a reader who has not yet worked out what an offset is will not get past it. + +```python +Disassembler(some_function, depths=True).live() # buttons work, needs `uv sync --extra live` +``` + +The argument can be a string, a function, a method or a code object, so a lesson can hand it the function it was just talking about rather than a copy of the text of it. + +## Why the browser decides nothing + +Python computes the rows and renders the HTML. The JavaScript puts that HTML on the page, listens for a click on a toggle and a keystroke in the source box, and hands both back to Python. It contains no opcode names, no cache arithmetic and no idea what an exception table is, and there is a test that greps it for opcode names to keep it that way. + +The reason is not tidiness. If the front end worked out which opcodes were specialized, there would be two answers to that question in this repository, and the one written in JavaScript is the one nobody runs a test against. It would drift, and it would drift in the direction of looking right, because the person who noticed would be a reader who trusted the widget over `dis` and got the wrong idea about CPython. Keeping the logic on one side means the picture in a rendered notebook and the picture you can click are the same picture by construction. + +It is also what makes the static rendering real. There is no code path where a widget is only correct once a browser is involved, so the version a reader sees on GitHub, in an nbconvert render, in a PDF export, or in the seconds before Pyodide has finished starting is a version with the right numbers in it. + +## Colour is never the only signal + +Every coloured thing in a widget is a chip, and `parts.chip` will not build one without a label. So the badge on a specialized opcode says the word "specialized" and not just a blue tint, which is what a reader with any of the common kinds of colour blindness needs, and also what survives a printed handout or a screenshot pasted into a chat with the saturation eaten by compression. Making the label a required argument is cheaper than writing that review comment every time. + +The toggles are real `