Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ dependencies = [
"pyxray",
"refcheck",
"xraymanim",
"xraywidgets",
]

# Installing the renderer. Manim brings cairo, pango, numpy and scipy with it, which is a
# lot to ask of a job that only wants to lint a notebook, so the animations are the one
# 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 = [
Expand All @@ -38,6 +42,7 @@ members = [
"tools/nbdiagram",
"tools/refcheck",
"xraymanim",
"xraywidgets",
]

[tool.uv.sources]
Expand All @@ -48,6 +53,7 @@ nbdiagram = { workspace = true }
pyxray = { workspace = true }
refcheck = { workspace = true }
xraymanim = { workspace = true }
xraywidgets = { workspace = true }

[tool.pytest.ini_options]
testpaths = [
Expand All @@ -58,6 +64,7 @@ testpaths = [
"tools/nbdiagram/tests",
"tools/refcheck/tests",
"xraymanim/tests",
"xraywidgets/tests",
]
addopts = "-q --strict-markers --strict-config"
markers = [
Expand All @@ -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
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions pyxray/src/pyxray/bytecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
]
54 changes: 54 additions & 0 deletions pyxray/tests/test_bytecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
91 changes: 90 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading