diff --git a/CLAUDE.md b/CLAUDE.md index 3b7af17..51a4090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 4d26f46..582bbfe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 01e9eeb..a91ef07 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/check-runtime-imports.py b/scripts/check-runtime-imports.py new file mode 100644 index 0000000..be3650a --- /dev/null +++ b/scripts/check-runtime-imports.py @@ -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"), 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.") diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index fd58056..eade513 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -1242,13 +1242,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { 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 diff --git a/src/runtime/session.py b/src/runtime/session.py index bbbebb2..65bcc5c 100644 --- a/src/runtime/session.py +++ b/src/runtime/session.py @@ -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