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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@ Worker messages:
- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop. The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`).
- **UI strings** come in via the `t: CoreStrings` prop. `src/i18n.ts` is their single source: `coreStrings` holds German and English, and `resolveUiLocale` picks the language. The embed uses them directly; the glpython IDE imports them and adds only its shell strings. New strings have been added as *optional* `CoreStrings` keys with an English fallback, so older hosts keep compiling; add both languages to `coreStrings`. Python values, reprs and error messages are never translated.
- **Two layouts:** with `compact` (the embed) the original tree (`main-split` > `editor-column` + graphics aside), unchanged and styled by `src/embed/style.css`; without it (the IDE) the stacked layout (`workspace-stack`: `stack-top` with the editor panel and, while drawing, the graphics, then `stack-divider` and `stack-console`). Both are assembled from the same JSX pieces (`actionsNode`, `visualizerBarNode`, `editorWrapNode`, `consoleNode`, …) just before the return. The buttons can be portaled into the host's bar (`actionsContainer`).
- **Embedded/reduced layouts:** the knobs are `compact`, `capabilities` (`WorkspaceCapabilities`, including `repl`) and `command` (`WorkspaceCommand`, an immutable `{id, type}` used by editor-free hosts to trigger run/visualize). Defaults must preserve the full-workspace behavior.
- **Embedded/reduced layouts:** the knobs are `compact`, `capabilities` (`WorkspaceCapabilities`, including `repl`) and `command` (`WorkspaceCommand`, an immutable `{id, type}` used by hosts to trigger run/visualize/test/stop/reset without the workspace's own buttons; `reset` restarts Python and clears the console). Defaults must preserve the full-workspace behavior.

**Embed protocol.** `src/embedProtocol.ts` defines the versioned message types and validators (`parseParentMessage`, `parseParentOrigin`, `GLPYTHON_EMBED_PROTOCOL_VERSION`, `GLPYTHON_MAX_SOURCE_LENGTH`), all re-exported from `src/index.ts`. The iframe (`src/embed/App.tsx`) requires an exact `parentOrigin` query param, sends `glpython:ready`/`glpython:resize`, and accepts `glpython:load`/`run`/`visualize`/`reset`. `run`/`visualize` carry the source snapshot so the parent remains the authoritative editor. Version 2 adds `files` + `mainFile` for programs of several files, version 3 adds `glpython:test` (doctests); version 4 adds `glpython:stop` and `glpython:busy`; the embed still accepts every earlier version and answers each parent in the version it last used, so older (e.g. cached) macros keep working. Modes: `eval` (with REPL), `turtle` (graphics layout), `visual` (visualizer-only, cannot exit). Reset remounts the workspace via a `key` generation counter, which also recreates the worker. Any protocol change should bump/handle the version and be covered in `scripts/check-embed-protocol.mjs`; also update `embed/demo.html` and the README.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ a visualization), the run button becomes **Stop** (`stop`: «Stoppen»). Stoppin
ends Python's worker and starts a new one: the code stays, the console keeps
what was printed and adds `stoppedNote`, and the REPL starts in an empty
namespace (earlier entries can't be visualized anymore). Hosts can stop with
the command `{ id, type: 'stop' }`, and learn when Python is busy through the
the command `{ id, type: 'stop' }` (and restart Python with an empty console,
as Reset does, with `{ id, type: 'reset' }`), and learn when Python is busy through the
`onBusyChange` prop.

### REPL
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"preview:embed": "vite preview --config vite.embed.config.ts",
"test:protocol": "npm run build && node scripts/check-embed-protocol.mjs",
"test:hints": "node scripts/check-error-hints.mjs",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py",
"prepare": "npm run build"
},
"peerDependencies": {
Expand Down
85 changes: 85 additions & 0 deletions scripts/check-runtime-imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Regression test: Run (and Tests, Visualize) imports the project's files as
they are now. A changed file is read again - also when its size stays the
same and it changes within a second - a new file is found, and a removed or
renamed file can't be imported under its old name anymore, as in CPython
(ModuleNotFoundError) - before, its old module stayed in memory.

session.py's _glpython_invalidate_modules gets the paths of the files mounted
now, as the worker's mountProjectFiles passes them, and remembers those of the
time before itself.

Runs the runtime with CPython (no Pyodide needed):
python3 -B scripts/check-runtime-imports.py
"""
import json
import os
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUNTIME = os.path.join(ROOT, "src", "runtime")

runtime = {"__name__": "glpython_runtime"}
for name in ("hints.py", "session.py"):
with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle:
exec(compile(handle.read(), "<exec>", "exec"), runtime)

folder = tempfile.mkdtemp(prefix="glpython-imports-")
sys.path.insert(0, folder)
mounted = []
failures = []


def mount(files):
"""As the worker's mountProjectFiles: remove the old files, write the new
ones, pass the new ones' paths."""
global mounted
for name in mounted:
os.remove(os.path.join(folder, name))
for name, source in files.items():
with open(os.path.join(folder, name), "w", encoding="utf-8") as handle:
handle.write(source)
mounted = list(files)
runtime["_glpython_invalidate_modules"](json.dumps([os.path.join(folder, name) for name in mounted]))


def run(main):
namespace = {"__name__": "__main__"}
try:
exec(main, namespace)
return namespace.get("result")
except BaseException as error:
return f"{type(error).__name__}: {error}"


def expect(label, got, want):
if got != want:
failures.append(f"{label}: got {got!r}, want {want!r}")


MAIN = "import helfer\nresult = helfer.wert()\n"
mount({"main.py": MAIN, "helfer.py": "def wert():\n return 1\n"})
expect("first run", run(MAIN), 1)
mount({"main.py": MAIN, "helfer.py": "def wert():\n return 2\n"})
expect("same size, at once", run(MAIN), 2)
mount({"main.py": MAIN, "helfer.py": "def wert():\n return 3\n"})
expect("same size, at once again", run(MAIN), 3)
mount({"main.py": MAIN, "helfer.py": "def wert():\n return 40\n"})
expect("other size", run(MAIN), 40)
mount({"main.py": "import neu\nresult = neu.X\n", "neu.py": "X = 5\n"})
expect("a new file", run("import neu\nresult = neu.X\n"), 5)
mount({"main.py": MAIN})
expect("helfer.py removed", run(MAIN), "ModuleNotFoundError: No module named 'helfer'")
mount({"main.py": MAIN, "helfer2.py": "def wert():\n return 7\n"})
expect("renamed to helfer2.py, imported as helfer2", run("import helfer2\nresult = helfer2.wert()\n"), 7)
expect("renamed, imported under the old name", run(MAIN), "ModuleNotFoundError: No module named 'helfer'")
# A module importing another project file (parser.py next to auswerter.py).
mount({"main.py": "import auswerter\nresult = auswerter.auswerten()\n", "auswerter.py": "from parser import parse\ndef auswerten():\n return parse()\n", "parser.py": "def parse():\n return 'alt'\n"})
expect("parser via auswerter", run("import auswerter\nresult = auswerter.auswerten()\n"), "alt")
mount({"main.py": "import auswerter\nresult = auswerter.auswerten()\n", "auswerter.py": "from parser import parse\ndef auswerten():\n return parse()\n", "parser.py": "def parse():\n return 'neu'\n"})
expect("changed parser via auswerter", run("import auswerter\nresult = auswerter.auswerten()\n"), "neu")

if failures:
print("\n".join(failures))
sys.exit(f"{len(failures)} import check(s) failed.")
print("Runtime import checks passed.")
10 changes: 3 additions & 7 deletions src/GlPythonWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@
{parens && '('}
{lines.map((line, index) => {
const lineStart = cursor
cursor += line.length + 1

Check warning on line 711 in src/GlPythonWorkspace.tsx

View workflow job for this annotation

GitHub Actions / check

react(immutability)

src/GlPythonWorkspace.tsx:711:11: Cannot reassign variable after render completes
return <Fragment key={index}>{index > 0 && '\n'}{renderSegments(lineSegments(line, lineStart))}</Fragment>
})}
{parens && ')'}
Expand Down Expand Up @@ -1242,13 +1242,9 @@
runTests()
}
if (command.type === 'stop') stopPython()
if (command.type === 'reset') {
exitVisualizer()
setConsoleEntries([])
setOutput(t.readyOutput)
setOutputErrorHint(null)
setGraphics([])
}
// Restarts Python and clears the console, as the Reset button does (the
// IDE's «Python neu starten» in its menu).
if (command.type === 'reset') resetPython()
// Commands are immutable events. Their id, rather than render-time callback
// identities, determines whether the effect should run again.
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
23 changes: 18 additions & 5 deletions src/runtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ def _glpython_error_message(error):
text = error.msg if isinstance(error, SyntaxError) and error.msg else str(error)
return f"{type(error).__name__}: {text}" if text else type(error).__name__

# Run, Tests and Visualize import the project's files as they are now: the
# modules of every file mounted now and at the time before are dropped, so a
# changed file is read again and a removed or renamed one can't be imported
# under its old name anymore (as in CPython: ModuleNotFoundError) - its old
# module used to stay. The import system's directory caches are cleared too,
# and no bytecode is written, so a file changed within the same second keeps
# no stale .pyc.
sys.dont_write_bytecode = True
_glpython_mounted_paths = set()

def _glpython_invalidate_modules(mounted_paths_json):
for mounted_path in json.loads(mounted_paths_json):
for module_name in list(sys.modules):
module = sys.modules[module_name]
if getattr(module, "__file__", None) == mounted_path:
del sys.modules[module_name]
global _glpython_mounted_paths
import importlib
mounted_paths = set(json.loads(mounted_paths_json))
for module_name in list(sys.modules):
if getattr(sys.modules[module_name], "__file__", None) in mounted_paths | _glpython_mounted_paths:
del sys.modules[module_name]
_glpython_mounted_paths = mounted_paths
importlib.invalidate_caches()

# Python's traceback for an error the program raised, as CPython prints it
# (carets, chained exceptions, a SyntaxError's caret line), without this
Expand Down
Loading