From 59a693e31e1611dbcf3227550243f4a0acbd74fb Mon Sep 17 00:00:00 2001 From: Jamie Benstead <57325966+jamiebenstead@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:09:06 +0000 Subject: [PATCH 1/6] Update version (#1197) ## What's Changed * Check instructions length before setting option value by @jamiebenstead in https://github.com/RaspberryPiFoundation/editor-ui/pull/1196 **Full Changelog**: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...v0.29.1 --- CHANGELOG.md | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e10c5248a..fc5a14d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.29.1] - 2025-02-21 ### Fixed @@ -1066,7 +1066,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Events in Web Component indicating whether Mission Zero criteria have been met (#113) -[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.0...HEAD +[unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.1...HEAD +[0.29.1]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.1 [0.29.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.0 [0.28.14]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.14 [0.28.13]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.13 diff --git a/package.json b/package.json index af4712906..ac2528a4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raspberrypifoundation/editor-ui", - "version": "0.29.0", + "version": "0.29.1", "private": true, "dependencies": { "@apollo/client": "^3.7.8", From 32315918c557b025f4d5dbc6148906812dcda3ab Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Mon, 24 Mar 2025 15:58:09 +0000 Subject: [PATCH 2/6] Fixing the append file write mode (#1200) ## What's Changed? - Fixed the mode on the `pyodide` side - it was always using `w` rather than the mode provided in the code - Fixed the file write append mode on the frontend - it was incorrectly adding a newline character before anything was appended. --- CHANGELOG.md | 6 ++++++ src/PyodideWorker.js | 2 +- .../Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx | 2 +- .../PythonRunner/PyodideRunner/PyodideRunner.test.js | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5a14d0f..0e42619de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Bugs in append mode for writing to files in python (#1200) + ## [0.29.1] - 2025-02-21 ### Fixed diff --git a/src/PyodideWorker.js b/src/PyodideWorker.js index 200c742b0..811467bb3 100644 --- a/src/PyodideWorker.js +++ b/src/PyodideWorker.js @@ -116,7 +116,7 @@ const PyodideWorker = () => { self.content += content if len(self.content) > MAX_FILE_SIZE: raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes") - with _original_open(self.filename, "w") as f: + with _original_open(self.filename, mode) as f: f.write(self.content) basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode }) diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx index 50594e250..0d39afe80 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx @@ -223,7 +223,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => { updatedContent = content; } else if (mode === "a") { updatedContent = - (componentToUpdate ? componentToUpdate.content + "\n" : "") + content; + (componentToUpdate ? componentToUpdate.content : "") + content; } if (componentToUpdate) { diff --git a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js index bea6c9264..fef816ebe 100644 --- a/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js +++ b/src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.test.js @@ -307,7 +307,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ @@ -364,7 +364,7 @@ describe("When file write event is received", () => { worker.postMessageFromWorker({ method: "handleFileWrite", filename: "existing_file.txt", - content: "new content", + content: "\nnew content", mode: "a", }); expect(dispatchSpy).toHaveBeenCalledWith({ From 8df5e6d26757f6ae7d1ef7524b5fef46d1821636 Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:21:51 +0100 Subject: [PATCH 3/6] Added project load failed custom event (#1201) --- CHANGELOG.md | 4 ++ src/containers/WebComponentLoader.jsx | 11 ++++- src/containers/WebComponentLoader.test.js | 60 ++++++++++++++++++++++- src/events/WebComponentCustomEvents.js | 4 ++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e42619de..d0c21414b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Added + +- `editor-projectLoadFailed` custom event that fires when a project completely fails to load (#1201) + ### Fixed - Bugs in append mode for writing to files in python (#1200) diff --git a/src/containers/WebComponentLoader.jsx b/src/containers/WebComponentLoader.jsx index d6cae9700..adaee80ba 100644 --- a/src/containers/WebComponentLoader.jsx +++ b/src/containers/WebComponentLoader.jsx @@ -26,7 +26,10 @@ import externalStyles from "../assets/stylesheets/ExternalStyles.scss"; import editorStyles from "../assets/stylesheets/index.scss"; import "../assets/stylesheets/Notifications.scss"; import Style from "style-it"; -import { projectOwnerLoadedEvent } from "../events/WebComponentCustomEvents"; +import { + projectLoadFailed, + projectOwnerLoadedEvent, +} from "../events/WebComponentCustomEvents"; const WebComponentLoader = (props) => { const { @@ -112,6 +115,12 @@ const WebComponentLoader = (props) => { } }, [loading, project]); + useEffect(() => { + if (loading === "failed" && !remixLoadFailed) { + document.dispatchEvent(projectLoadFailed); + } + }, [loading, remixLoadFailed]); + useEffect(() => { if (justLoaded) { document.dispatchEvent(projectOwnerLoadedEvent(projectOwner)); diff --git a/src/containers/WebComponentLoader.test.js b/src/containers/WebComponentLoader.test.js index c8a7df9d0..ed42a94c0 100644 --- a/src/containers/WebComponentLoader.test.js +++ b/src/containers/WebComponentLoader.test.js @@ -535,7 +535,7 @@ describe("When user is in state", () => { const mockStore = configureStore(middlewares); const initialState = { editor: { - loading: "idle", + loading: "failed", project: { components: [], }, @@ -581,6 +581,16 @@ describe("When user is in state", () => { reactAppApiEndpoint: "http://localhost:3009/", }); }); + + test("Does not trigger project load failed event", () => { + expect(document.dispatchEvent).not.toHaveBeenCalledWith( + new CustomEvent("editor-projectLoadFailed", { + bubbles: true, + cancelable: false, + composed: true, + }), + ); + }); }); afterEach(async () => { @@ -588,4 +598,52 @@ describe("When user is in state", () => { await act(async () => cookies.remove("theme")); }); }); + + describe("when a project fails to load", () => { + beforeEach(() => { + localStorage.setItem(authKey, JSON.stringify(user)); + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + loading: "failed", + project: { + components: [], + }, + openFiles: [], + focussedFileIndices: [], + hasShownSavePrompt: false, + remixLoadFailed: false, + justLoaded: false, + saveTriggered: false, + }, + instructions: {}, + auth: { user }, + }; + store = mockStore(initialState); + cookies = new Cookies(); + render( + + + + + , + ); + }); + + test("triggers project load failed event", () => { + expect(document.dispatchEvent).toHaveBeenCalledWith( + new CustomEvent("editor-projectLoadFailed", { + bubbles: true, + cancelable: false, + composed: true, + }), + ); + }); + }); }); diff --git a/src/events/WebComponentCustomEvents.js b/src/events/WebComponentCustomEvents.js index 6378b294d..b79b52d20 100644 --- a/src/events/WebComponentCustomEvents.js +++ b/src/events/WebComponentCustomEvents.js @@ -15,6 +15,10 @@ export const navigateToProjectsPageEvent = webComponentCustomEvent( export const projectIdentifierChangedEvent = (detail) => webComponentCustomEvent("editor-projectIdentifierChanged", detail); +export const projectLoadFailed = webComponentCustomEvent( + "editor-projectLoadFailed", +); + export const projectOwnerLoadedEvent = (detail) => webComponentCustomEvent("editor-projectOwnerLoaded", detail); From fb808cc97ef069359f6d630229dace33e4bd1047 Mon Sep 17 00:00:00 2001 From: Jamie Benstead <57325966+jamiebenstead@users.noreply.github.com> Date: Fri, 11 Apr 2025 16:15:55 +0100 Subject: [PATCH 4/6] Add runnerBeingLoaded logic (#1205) closes https://github.com/RaspberryPiFoundation/editor-ui/issues/1202 --- CHANGELOG.md | 1 + src/redux/EditorSlice.js | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c21414b..369c09fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - `editor-projectLoadFailed` custom event that fires when a project completely fails to load (#1201) +- Added runnerBeingLoaded state to prevent race condition overwrites (#1205) ### Fixed diff --git a/src/redux/EditorSlice.js b/src/redux/EditorSlice.js index be193b1f1..60f47acd1 100644 --- a/src/redux/EditorSlice.js +++ b/src/redux/EditorSlice.js @@ -140,6 +140,7 @@ export const editorInitialState = { sidebarShowing: true, modals: {}, errorDetails: {}, + runnerBeingLoaded: null | "pyodide" | "skulpt", }; export const EditorSlice = createSlice({ @@ -323,12 +324,15 @@ export const EditorSlice = createSlice({ state.drawTriggered = false; }, loadingRunner: (state, action) => { + state.runnerBeingLoaded = action.payload; state.activeRunner = action.payload; state.codeRunLoading = true; }, setLoadedRunner: (state, action) => { - state.loadedRunner = action.payload; - state.codeRunLoading = false; + if (state.runnerBeingLoaded === action.payload) { + state.loadedRunner = action.payload; + state.codeRunLoading = false; + } }, resetRunner: (state) => { state.activeRunner = null; @@ -339,6 +343,7 @@ export const EditorSlice = createSlice({ state.codeRunLoading = false; state.codeRunTriggered = false; state.codeRunStopped = false; + state.runnerBeingLoaded = null; }, closeAccessDeniedWithAuthModal: (state) => { state.accessDeniedWithAuthModalShowing = false; From f522306fd4fc2160c91f029b23d83802953c5e41 Mon Sep 17 00:00:00 2001 From: Lois Wells <88904316+loiswells97@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:28:34 +0100 Subject: [PATCH 5/6] Fix turtle canvas bug (#1203) This PR is very much WIP Although these changes fix the original bug, they introduce an issue with the `p5` canvas that needs investigating before this can go out. Steps to reproduce: 1. Run a basic `p5` program 2. Delete that code and run `print("hello world")` 3. Run the basic `p5` program again Expected behaviour: Output from the `p5` program is displayed in the visual output tab Actual behaviour: The visual output tab is rendered, but no output is visible closes https://github.com/RaspberryPiFoundation/digital-editor-issues/issues/137 --- CHANGELOG.md | 1 + cypress/e2e/spec-wc-skulpt.cy.js | 2 +- .../Runners/PythonRunner/PythonRunner.jsx | 25 +---- .../SkulptRunner/SkulptRunner.jsx | 97 +++++++++++++------ .../SkulptRunner/SkulptRunner.test.js | 19 ++-- src/utils/getPythonImports.js | 22 +++++ 6 files changed, 104 insertions(+), 62 deletions(-) create mode 100644 src/utils/getPythonImports.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 369c09fbb..a1b6d33e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Bugs in append mode for writing to files in python (#1200) +- `turtle` bug that did not display output on first code run (#1203) ## [0.29.1] - 2025-02-21 diff --git a/cypress/e2e/spec-wc-skulpt.cy.js b/cypress/e2e/spec-wc-skulpt.cy.js index 5b733ca8a..db79cf1b4 100644 --- a/cypress/e2e/spec-wc-skulpt.cy.js +++ b/cypress/e2e/spec-wc-skulpt.cy.js @@ -38,7 +38,7 @@ describe("Running the code with skulpt", () => { .shadow() .find(".skulptrunner") .contains(".react-tabs__tab", "Visual output") - .should("not.exist"); + .should("not.be.visible"); cy.get("editor-wc") .shadow() .find(".skulptrunner") diff --git a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx index 85baefbbf..60ede272e 100644 --- a/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/PythonRunner.jsx @@ -6,6 +6,7 @@ import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { loadingRunner } from "../../../../redux/EditorSlice"; +import { getPythonImports } from "../../../../utils/getPythonImports"; const SKULPT_ONLY_MODULES = [ "p5", @@ -45,33 +46,11 @@ const PythonRunner = ({ outputPanels = ["text", "visual"] }) => { setSkulptFallback(true); return; } - const getImports = (code) => { - const codeWithoutMultilineStrings = code.replace( - /'''[\s\S]*?'''|"""[\s\S]*?"""/gm, - "", - ); - const importRegex = - /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm; - const matches = codeWithoutMultilineStrings.match(importRegex); - const imports = matches - ? matches.map( - (match) => - match - .split(/from|import/) - .filter(Boolean) - .map((s) => s.trim())[0], - ) - : []; - if (code.includes(`# ${t("input.comment.py5")}`)) { - imports.push("py5_imported"); - } - return imports; - }; for (const component of project.components || []) { if (component.extension === "py" && !codeRunTriggered) { try { - const imports = getImports(component.content); + const imports = getPythonImports(component.content, t); const hasSkulptOnlyModules = imports.some((name) => SKULPT_ONLY_MODULES.includes(name), ); diff --git a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx index 99c548d78..3fbe45cfe 100644 --- a/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx +++ b/src/components/Editor/Runners/PythonRunner/SkulptRunner/SkulptRunner.jsx @@ -24,6 +24,7 @@ import OutputViewToggle from "../OutputViewToggle"; import { SettingsContext } from "../../../../../utils/settings"; import RunnerControls from "../../../../RunButton/RunnerControls"; import { MOBILE_MEDIA_QUERY } from "../../../../../utils/mediaQueryBreakpoints"; +import { getPythonImports } from "../../../../../utils/getPythonImports"; const externalLibraries = { "./pygal/__init__.js": { @@ -55,6 +56,15 @@ const externalLibraries = { }, }; +const VISUAL_LIBRARIES = [ + "pygal", + "py5", + "py5_imported", + "p5", + "sense_hat", + "turtle", +]; + const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const loadedRunner = useSelector((state) => state.editor.loadedRunner); const projectCode = useSelector((state) => state.editor.project.components); @@ -83,10 +93,31 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { const settings = useContext(SettingsContext); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); + const project = useSelector((state) => state.editor.project); + + const testForVisualImports = (project) => { + for (const component of project.components || []) { + if (component.extension === "py") { + try { + const imports = getPythonImports(component.content, t); + const hasVisualImports = imports.some((name) => + VISUAL_LIBRARIES.includes(name), + ); + if (hasVisualImports) { + return true; + } + } catch (error) { + console.error("Error occurred while getting imports:", error); + } + } + } + return false; + }; + const [codeHasVisualOutput, setCodeHasVisualOutput] = useState( - senseHatAlwaysEnabled, + !!senseHatAlwaysEnabled || testForVisualImports(project), ); - const [showVisualOutput, setShowVisualOutput] = useState(true); + const [showVisualOutput, setShowVisualOutput] = useState(codeHasVisualOutput); const getInput = () => { const pageInput = document.getElementById("input"); @@ -96,6 +127,14 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { return pageInput || webComponentInput; }; + useEffect(() => { + if (!codeRunTriggered) { + setCodeHasVisualOutput( + !!senseHatAlwaysEnabled || testForVisualImports(project), + ); + } + }, [project, codeRunTriggered, senseHatAlwaysEnabled, t]); + useEffect(() => { if (active && loadedRunner !== "skulpt") { dispatch(setLoadedRunner("skulpt")); @@ -136,15 +175,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { } }, [drawTriggered, codeRunTriggered]); - const visualLibraries = [ - "./pygal/__init__.js", - "./py5/__init__.js", - "./py5_imported/__init__.js", - "./p5/__init__.js", - "./_internal_sense_hat/__init__.js", - "src/builtin/turtle/__init__.js", - ]; - const outf = (text) => { if (text !== "") { const node = output.current; @@ -169,10 +199,6 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { // TODO: Handle pre-importing py5_imported when refactored py5 shim imported - if (visualLibraries.includes(library)) { - setCodeHasVisualOutput(true); - } - let localProjectFiles = projectCode .filter((component) => component.name !== "main") .map((component) => `./${component.name}.py`); @@ -436,6 +462,18 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { }); }; + const [tabbedViewSelectedIndex, setTabbedViewSelectedIndex] = useState( + showVisualOutput ? 0 : 1, + ); + + useEffect(() => { + if (showVisualOutput) { + setTabbedViewSelectedIndex(0); + } else { + setTabbedViewSelectedIndex(1); + } + }, [showVisualOutput]); + return (
{ > {isSplitView || singleOutputPanel ? ( <> - {showVisualOutput && showVisualOutputPanel && ( -
+ {showVisualOutputPanel && ( +
{
- {showVisualOutput ? ( - - - {t("output.visualOutput")} - - - ) : null} + + + {t("output.visualOutput")} + + {t("output.textOutput")} @@ -523,11 +564,9 @@ const SkulptRunner = ({ active, outputPanels = ["text", "visual"] }) => { {!isEmbedded && isMobile && }
{!isOutputOnly && } - {showVisualOutput ? ( - - - - ) : null} + + +
 {
             {
               name: "main",
               extension: "py",
-              content: "import py5_imported",
+              content: "# input.comment.py5",
             },
           ],
           image_list: [],
@@ -563,7 +563,7 @@ describe("When in split view, sense_hat imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import _internal_sense_hat",
+              content: "import sense_hat",
             },
           ],
           image_list: [],
@@ -675,7 +675,7 @@ describe("When in tabbed view, py5 imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -704,7 +704,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import py5_imported",
+              content: "# input.comment.py5",
             },
           ],
           image_list: [],
@@ -724,7 +724,7 @@ describe("When in tabbed view, py5_imported imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -769,7 +769,7 @@ describe("When in tabbed view, pygal imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -814,7 +814,8 @@ describe("When in tabbed view, turtle imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
+    screen.debug();
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
@@ -839,7 +840,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => {
             {
               name: "main",
               extension: "py",
-              content: "import _internal_sense_hat",
+              content: "import sense_hat",
             },
           ],
           image_list: [],
@@ -859,7 +860,7 @@ describe("When in tabbed view, sense_hat imported and code run", () => {
     ));
   });
 
-  test("Output view toggle not shown", () => {
+  test("Output view toggle is shown", () => {
     expect(
       screen.queryByText("outputViewToggle.buttonSplitLabel"),
     ).toBeInTheDocument();
diff --git a/src/utils/getPythonImports.js b/src/utils/getPythonImports.js
new file mode 100644
index 000000000..e06c4f383
--- /dev/null
+++ b/src/utils/getPythonImports.js
@@ -0,0 +1,22 @@
+export const getPythonImports = (code, t) => {
+  const codeWithoutMultilineStrings = code.replace(
+    /'''[\s\S]*?'''|"""[\s\S]*?"""/gm,
+    "",
+  );
+  const importRegex =
+    /(?<=^\s*)(from\s+([a-zA-Z0-9_.]+)(\s+import\s+([a-zA-Z0-9_.]+))?)|(?<=^\s*)(import\s+([a-zA-Z0-9_.]+))/gm;
+  const matches = codeWithoutMultilineStrings.match(importRegex);
+  const imports = matches
+    ? matches.map(
+        (match) =>
+          match
+            .split(/from|import/)
+            .filter(Boolean)
+            .map((s) => s.trim())[0],
+      )
+    : [];
+  if (code.includes(`# ${t("input.comment.py5")}`)) {
+    imports.push("py5_imported");
+  }
+  return imports;
+};

From 29b3b3faca403443acc16b5b5888fd2c646ae02f Mon Sep 17 00:00:00 2001
From: Lois Wells <88904316+loiswells97@users.noreply.github.com>
Date: Tue, 15 Apr 2025 11:19:32 +0100
Subject: [PATCH 6/6] v0.30.0 (#1207)

### Added
- `editor-projectLoadFailed` custom event that fires when a project
completely fails to load (#1201)
- Added runnerBeingLoaded state to prevent race condition overwrites
(#1205)

### Fixed
- Bugs in append mode for writing to files in python (#1200)
- `turtle` bug that did not display output on first code run (#1203)
---
 CHANGELOG.md | 3 +++
 package.json | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1b6d33e3..b3d7fb495 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## Unreleased
 
+## [0.30.0] - 2025-04-15
+
 ### Added
 
 - `editor-projectLoadFailed` custom event that fires when a project completely fails to load (#1201)
@@ -1079,6 +1081,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 - Events in Web Component indicating whether Mission Zero criteria have been met (#113)
 
 [unreleased]: https://github.com/RaspberryPiFoundation/editor-ui/compare/v0.29.1...HEAD
+[0.30.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.30.0
 [0.29.1]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.1
 [0.29.0]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.29.0
 [0.28.14]: https://github.com/RaspberryPiFoundation/editor-ui/releases/tag/v0.28.14
diff --git a/package.json b/package.json
index ac2528a4e..63d3a2617 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@raspberrypifoundation/editor-ui",
-  "version": "0.29.1",
+  "version": "0.30.0",
   "private": true,
   "dependencies": {
     "@apollo/client": "^3.7.8",