Skip to content
Open
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
7 changes: 6 additions & 1 deletion src/backend/workers/python/build_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import subprocess
import sys
import sysconfig

def tarfile_filter(tar_info):
name = tar_info.name
Expand All @@ -27,6 +28,10 @@ def create_package(package_name, dependencies, extra_deps):
except Exception as e:
# Always seems to result in a harmless permission denied error
pass
# Bundle CPython's turtle.py (removed from Pyodide's stdlib, required by svg-turtle).
# Locate via sysconfig rather than `import turtle`, which would pull in tkinter.
turtle_src = os.path.join(sysconfig.get_path("stdlib"), "turtle.py")
shutil.copy(turtle_src, os.path.join(package_name, "turtle.py"))
tar_name = f"{package_name}.tar.gz.load_by_url"
if os.path.exists(tar_name):
os.remove(tar_name)
Expand All @@ -45,4 +50,4 @@ def check_tar(tarname, out_dir="."):


if __name__ == "__main__":
create_package("python_package", "python-runner friendly_traceback pylint>=4,<5 tomli typing-extensions json-tracer>=0.7.0", extra_deps="papyros")
create_package("python_package", "python-runner friendly_traceback pylint>=4,<5 tomli typing-extensions json-tracer>=0.7.0 svg-turtle", extra_deps="papyros")
2 changes: 1 addition & 1 deletion src/backend/workers/python/papyros/linting.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


PYLINT_RC_FILE = os.path.abspath("/tmp/papyros/pylint_config.rc")
PYLINT_PLUGINS = "pylint_ast_checker"
PYLINT_PLUGINS = "pylint_ast_checker,pylint_turtle_brain"

def lint(code):
# Use temporary file to prevent Astroid cache from running into issues
Expand Down
31 changes: 31 additions & 0 deletions src/backend/workers/python/papyros/papyros.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
from pyodide_worker_runner import install_imports
from pyodide.ffi import JsException, create_proxy
from .util import to_py
from .turtle_hook import TurtleImportHook
from pyodide.http import pyfetch
from types import ModuleType

SYS_RECURSION_LIMIT = 500
MODULE_NAME = "sandbox"


class Papyros(python_runner.PyodideRunner):
def __init__(
self,
Expand All @@ -47,6 +49,8 @@ def __init__(
self._tracking_files = False
self._original_open = builtins.open
self._last_emitted_snapshot = None
self._last_emitted_turtle_svg = None
self._turtle_hook = TurtleImportHook()
self._install_open_tracking()
self.limit = limit
self.override_globals()
Expand Down Expand Up @@ -74,6 +78,7 @@ def cb(typ, dat, contentType=None, **kwargs):
elif event_type == "input":
return cb("input", data["prompt"])
elif event_type == "sleep":
self._emit_turtle_snapshot()
return cb("sleep", data["seconds"]*1000, contentType="application/number")
else:
return cb(event_type, data.get("data", ""), contentType=data.get("contentType"))
Expand All @@ -86,6 +91,26 @@ def override_globals(self):
# Otherwise `import matplotlib` fails while assuming a browser backend
os.environ["MPLBACKEND"] = "AGG"
self.override_matplotlib()
self.override_turtle()

def _emit_turtle_snapshot(self):
if not self._turtle_hook.render:
return
from svg_turtle import SvgTurtle
svg_string = SvgTurtle._pen.to_svg()
if svg_string and svg_string != self._last_emitted_turtle_svg:
self._last_emitted_turtle_svg = svg_string
img = base64.b64encode(svg_string.encode("utf-8")).decode("utf-8")
self.callback("turtle", data=img, contentType="image/svg+xml;base64")

def override_turtle(self):
hook = self._turtle_hook
hook.papyros = self
hook.render = None
# Remove turtle from sys.modules so the hook intercepts the next import
sys.modules.pop('turtle', None)
if hook not in sys.meta_path:
sys.meta_path.insert(0, hook)

def override_matplotlib(self):
try:
Expand Down Expand Up @@ -196,6 +221,7 @@ def _execute_context(self):
self._tracked_files.clear()
self._tracking_files = True
self._last_emitted_snapshot = None
self._last_emitted_turtle_svg = None
with (
redirect_stdout(python_runner.output.SysStream("output", self.output_buffer)),
redirect_stderr(python_runner.output.SysStream("error", self.output_buffer)),
Expand All @@ -206,6 +232,7 @@ def _execute_context(self):
self.output("traceback", **self.serialize_traceback(e))
self._flush_open_files()
self._emit_created_files()
self._emit_turtle_snapshot()
finally:
self._tracking_files = False
self.post_run()
Expand All @@ -229,9 +256,11 @@ async def run_async(self, source_code, mode="exec", top_level_await=True):
self.callback("start", data="RunCode", contentType="text/plain")
if mode == "debug":
from tracer import JSONTracer

def frame_callback(frame):
self._flush_open_files()
self._emit_created_files()
self._emit_turtle_snapshot()
self.callback("frame", data=frame, contentType="application/json")

result = JSONTracer(frame_callback=frame_callback, module_name=MODULE_NAME).runscript(source_code)
Expand All @@ -241,6 +270,7 @@ def frame_callback(frame):
result = await result
self._flush_open_files()
self._emit_created_files()
self._emit_turtle_snapshot()
self.callback("end", data="CodeFinished", contentType="text/plain")
return result
except ModuleNotFoundError as mnf:
Expand All @@ -257,6 +287,7 @@ def frame_callback(frame):
# with a js_error containing the reason
js_error = str(getattr(e, "js_error", ""))
if isinstance(e, KeyboardInterrupt) or "KeyboardInterrupt" in js_error:
self._emit_turtle_snapshot()
self.callback("interrupt", data="KeyboardInterrupt", contentType="text/plain")
else:
raise
Expand Down
59 changes: 59 additions & 0 deletions src/backend/workers/python/papyros/pylint_turtle_brain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Astroid brain plugin for Python's turtle module.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: note sure if this is the cleanest thing to teach pylint about the Turtle methods 🤔

# turtle.py creates module-level functions dynamically via exec() in
# _make_global_funcs(), so astroid can't see them statically.
# This plugin injects stubs for those functions so pylint can check
# valid vs invalid member access (e.g. turtle.forward is OK,
# turtle.test is not).

import astroid
from astroid import MANAGER

# These lists mirror CPython 3.13's turtle._tg_turtle_functions and
# turtle._tg_screen_functions — the functions generated at module level.
_TG_TURTLE_FUNCTIONS = [
'back', 'backward', 'begin_fill', 'begin_poly', 'bk',
'circle', 'clear', 'clearstamp', 'clearstamps', 'clone', 'color',
'degrees', 'distance', 'dot', 'down', 'end_fill', 'end_poly', 'fd',
'fillcolor', 'filling', 'forward', 'get_poly', 'getpen', 'getscreen',
'getturtle', 'goto', 'heading', 'hideturtle', 'home', 'ht', 'isdown',
'isvisible', 'left', 'lt', 'onclick', 'ondrag', 'onrelease', 'pd',
'pen', 'pencolor', 'pendown', 'pensize', 'penup', 'pos', 'position',
'pu', 'radians', 'right', 'reset', 'resizemode', 'rt', 'seth',
'setheading', 'setpos', 'setposition', 'settiltangle',
'setundobuffersize', 'setx', 'sety', 'shape', 'shapesize',
'shapetransform', 'shearfactor', 'showturtle', 'speed', 'st', 'stamp',
'teleport', 'tilt', 'tiltangle', 'towards', 'turtlesize', 'undo',
'undobuffercount', 'up', 'width', 'write', 'xcor', 'ycor',
]

_TG_SCREEN_FUNCTIONS = [
'addshape', 'bgcolor', 'bgpic', 'bye', 'clearscreen', 'colormode',
'delay', 'exitonclick', 'getcanvas', 'getshapes', 'listen',
'mainloop', 'mode', 'numinput', 'onkey', 'onkeypress', 'onkeyrelease',
'onscreenclick', 'ontimer', 'register_shape', 'resetscreen',
'screensize', 'setup', 'setworldcoordinates', 'textinput', 'title',
'tracer', 'turtles', 'update', 'window_height', 'window_width', 'done',
]

_ALL_FUNCTIONS = _TG_TURTLE_FUNCTIONS + _TG_SCREEN_FUNCTIONS


def _turtle_transform(module):
"""Add stub definitions for turtle's dynamically generated functions."""
code = "\n".join(f"def {name}(*args, **kwargs): ..." for name in _ALL_FUNCTIONS)
fake = astroid.parse(code)
for node in fake.body:
module.body.append(node)
module.locals[node.name] = [node]


MANAGER.register_transform(
astroid.Module,
_turtle_transform,
lambda node: node.name == 'turtle',
)


def register(linter):
"""Required by pylint plugin interface (no checkers to register)."""
pass
69 changes: 69 additions & 0 deletions src/backend/workers/python/papyros/turtle_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import sys


class TurtleImportHook:
"""Import hook that lazily sets up SVG-based turtle graphics.

Installed in sys.meta_path. When user code does `import turtle`,
this hook intercepts it, imports svg-turtle, creates a shared
canvas/screen, and patches the turtle module to render SVG.

If user code never imports turtle, no setup occurs.
"""

def __init__(self):
self.papyros = None
self.render = None
self._loading = False
self._turtle_module = None

def find_spec(self, name, path, target=None):
if name == 'turtle' and not self._loading:
import importlib.util
return importlib.util.spec_from_loader(name, self)
return None

def create_module(self, spec):
return self._setup_turtle()

def exec_module(self, module):
pass

def _setup_turtle(self):
self._loading = True
try:
from svg_turtle import SvgTurtle
from svg_turtle.canvas import Canvas

if self._turtle_module is None:
# svg_turtle stubs tkinter as a side effect of import; must precede `import turtle`
import turtle
self._turtle_module = sys.modules['turtle']

turtle_mod = self._turtle_module
sys.modules['turtle'] = turtle_mod

canvas = Canvas(400, 400)
screen = SvgTurtle._Screen(canvas)
screen.cv.config(bg="")

class PapyrosTurtle(SvgTurtle):
def __init__(self):
super().__init__(screen=screen)

SvgTurtle._screen = screen
SvgTurtle._pen = PapyrosTurtle()

def render():
self.papyros._emit_turtle_snapshot()

turtle_mod.Turtle = PapyrosTurtle
turtle_mod.done = render
turtle_mod.mainloop = render
turtle_mod.exitonclick = render
turtle_mod.bye = render

self.render = render
return turtle_mod
finally:
self._loading = False
1 change: 1 addition & 0 deletions src/communication/BackendEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum BackendEventType {
FrameChange = "frame-change",
Stop = "stop",
Files = "files",
Turtle = "turtle",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: added this type so we can act on turtle images vs. regular images. Previously in this branch, we did comparison based on the content type (img -> regular image, svg -> turtle images), but I found that too brittle.

}

/**
Expand Down
Loading
Loading