diff --git a/src/backend/workers/python/build_package.py b/src/backend/workers/python/build_package.py
index 7a8314b6..ad073931 100644
--- a/src/backend/workers/python/build_package.py
+++ b/src/backend/workers/python/build_package.py
@@ -4,6 +4,7 @@
import os
import subprocess
import sys
+import sysconfig
def tarfile_filter(tar_info):
name = tar_info.name
@@ -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)
@@ -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")
diff --git a/src/backend/workers/python/papyros/linting.py b/src/backend/workers/python/papyros/linting.py
index fafc6866..225e8765 100644
--- a/src/backend/workers/python/papyros/linting.py
+++ b/src/backend/workers/python/papyros/linting.py
@@ -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
diff --git a/src/backend/workers/python/papyros/papyros.py b/src/backend/workers/python/papyros/papyros.py
index deafa3b0..d15df0be 100644
--- a/src/backend/workers/python/papyros/papyros.py
+++ b/src/backend/workers/python/papyros/papyros.py
@@ -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,
@@ -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()
@@ -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"))
@@ -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:
@@ -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)),
@@ -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()
@@ -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)
@@ -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:
@@ -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
diff --git a/src/backend/workers/python/papyros/pylint_turtle_brain.py b/src/backend/workers/python/papyros/pylint_turtle_brain.py
new file mode 100644
index 00000000..d0894c6d
--- /dev/null
+++ b/src/backend/workers/python/papyros/pylint_turtle_brain.py
@@ -0,0 +1,59 @@
+# Astroid brain plugin for Python's turtle module.
+# 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
diff --git a/src/backend/workers/python/papyros/turtle_hook.py b/src/backend/workers/python/papyros/turtle_hook.py
new file mode 100644
index 00000000..fcb820b1
--- /dev/null
+++ b/src/backend/workers/python/papyros/turtle_hook.py
@@ -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
diff --git a/src/communication/BackendEvent.ts b/src/communication/BackendEvent.ts
index 929479fa..81ddbc62 100644
--- a/src/communication/BackendEvent.ts
+++ b/src/communication/BackendEvent.ts
@@ -14,6 +14,7 @@ export enum BackendEventType {
FrameChange = "frame-change",
Stop = "stop",
Files = "files",
+ Turtle = "turtle",
}
/**
diff --git a/src/frontend/components/Output.ts b/src/frontend/components/Output.ts
index cdfafc33..4d4ce60f 100644
--- a/src/frontend/components/Output.ts
+++ b/src/frontend/components/Output.ts
@@ -1,7 +1,8 @@
import { customElement } from "lit/decorators.js";
import { css, CSSResult, html, TemplateResult } from "lit";
-import { FriendlyError, OutputEntry, OutputType } from "../state/InputOutput";
+import { FriendlyError, OutputEntry, OutputType, OUTPUT_TAB, TURTLE_TAB } from "../state/InputOutput";
import { PapyrosElement } from "./PapyrosElement";
+import { tabButtonStyles } from "./shared-styles";
import "@material/web/icon/icon";
@customElement("p-output")
@@ -11,8 +12,32 @@ export class Output extends PapyrosElement {
:host {
width: 100%;
height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .tabs {
+ display: flex;
+ flex-direction: row;
+ gap: 0.25rem;
+ padding-top: 0.25rem;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+ }
+
+ .content {
+ flex: 1;
overflow: auto;
- display: block;
+ container-type: size;
+ padding: 0.75rem;
+ background-color: var(--md-sys-color-surface-container-highest);
+ }
+
+ .content.turtle {
+ margin-top: -1px;
+ padding: 0;
+ background-color: transparent;
}
img {
@@ -22,6 +47,18 @@ export class Output extends PapyrosElement {
margin: 0.5rem 0;
}
+ img.turtle,
+ .turtle-placeholder {
+ width: min(100cqw, 100cqh);
+ height: min(100cqw, 100cqh);
+ max-width: 100%;
+ max-height: 100%;
+ margin: 0;
+ box-sizing: border-box;
+ background-color: var(--md-sys-color-surface-container-highest);
+ border: 1px solid var(--md-sys-color-outline-variant);
+ }
+
pre {
font-family: monospace;
margin: 0;
@@ -39,6 +76,8 @@ export class Output extends PapyrosElement {
md-icon {
vertical-align: bottom;
}
+
+ ${tabButtonStyles}
`;
}
@@ -65,7 +104,7 @@ export class Output extends PapyrosElement {
get downloadOverflowUrl(): string {
const blob = new Blob(
this.overflow.map((o) => {
- if (o.type === OutputType.img) {
+ if (o.type === OutputType.img || o.type === OutputType.turtle) {
return `[Image output of type ${o.contentType} omitted]\n`;
} else if (o.type === OutputType.stdout) {
return o.content as string;
@@ -97,11 +136,24 @@ export class Output extends PapyrosElement {
}
get renderedOutputs(): TemplateResult[] {
- return this.outputs.map((o) => {
+ let outputsToRender: OutputEntry[];
+ if (this.papyros.io.activeOutputTab === TURTLE_TAB) {
+ // Latest snapshot within this.outputs (which is sliced by the debugger's current
+ // step via maxOutputLength) — so stepping the debugger shows the drawing build up.
+ const lastIdx = this.outputs.findLastIndex((o) => o.type === OutputType.turtle);
+ outputsToRender = lastIdx >= 0 ? [this.outputs[lastIdx]] : [];
+ } else {
+ outputsToRender = this.outputs.filter((o) => o.type !== OutputType.turtle);
+ }
+ return outputsToRender.map((o) => {
if (o.type === OutputType.stdout) {
return html`${o.content}`;
} else if (o.type === OutputType.img) {
- return html``;
+ const mimeType = o.contentType?.replace(/^img\//, "image/") ?? "image/png";
+ return html`
`;
+ } else if (o.type === OutputType.turtle) {
+ const mimeType = o.contentType ?? "image/svg+xml;base64";
+ return html`
`;
} else if (o.type === OutputType.stderr) {
if (typeof o.content === "string") {
return html`${o.content}`;
@@ -130,23 +182,59 @@ export class Output extends PapyrosElement {
});
}
- protected override render(): TemplateResult {
- if (this.outputs.length === 0) {
- return html`
${this.t("Papyros.output_placeholder")}`;
- }
+ private get showTurtleTab(): boolean {
+ return this.papyros.io.hasTurtleOutput || this.papyros.io.activeOutputTab === TURTLE_TAB;
+ }
+ private renderTabs(): TemplateResult {
+ const activeTab = this.papyros.io.activeOutputTab;
+ return html`
+ ${this.renderedOutputs}
- ${this.showOverflowWarning
- ? html`
- - ${this.t("Papyros.output_overflow")} - - ${this.t("Papyros.output_overflow_download")} - -
- ` - : html``} + ${this.renderTabs()} +${this.t("Papyros.output_placeholder")}`
+ : showTurtlePlaceholder
+ ? html``
+ : html`${rendered}`}
+ ${showOverflow
+ ? html`
+ + ${this.t("Papyros.output_overflow")} + + ${this.t("Papyros.output_overflow_download")} + +
+ ` + : html``} +