Browse Source

Merge pull request #32781 from overleaf/cd-expose-project-files-to-python-output

Cd expose project files to python output

GitOrigin-RevId: 410964cc27dd87e795ccb77365dda8344d72f8bf
Chris Dryden 3 tháng trước cách đây
mục cha
commit
e3ebc52334

+ 24 - 6
services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx

@@ -5,6 +5,7 @@ import OLButton from '@/shared/components/ol/ol-button'
 import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
 import MaterialIcon from '@/shared/components/material-icon'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
+import { useProjectContext } from '@/shared/context/project-context'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { debugConsole } from '@/utils/debugging'
 import getMeta from '@/utils/meta'
@@ -14,6 +15,7 @@ export default function PythonOutputPane() {
   const { t } = useTranslation()
   const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
   const { pathInFolder } = useFileTreePathContext()
+  const { projectSnapshot } = useProjectContext()
   const clientRef = useRef<PyodideWorkerClient | null>(null)
   const currentRequestIdRef = useRef<string | null>(null)
   const [isReady, setIsReady] = useState(false)
@@ -108,7 +110,21 @@ export default function PythonOutputPane() {
     }
   }, [currentDocument, currentDocumentId, pathInFolder])
 
-  const handleRun = useCallback(() => {
+  const getLatestProjectFiles = useCallback(async () => {
+    await projectSnapshot.refresh()
+    return projectSnapshot.getDocPaths().reduce(
+      (files, relativePath) => {
+        const content = projectSnapshot.getDocContents(relativePath)
+        if (content !== null) {
+          files.push({ relativePath, content })
+        }
+        return files
+      },
+      [] as { relativePath: string; content: string }[]
+    )
+  }, [projectSnapshot])
+
+  const handleRun = useCallback(async () => {
     const client = clientRef.current
     if (!client || !isReady) {
       return
@@ -126,17 +142,19 @@ export default function PythonOutputPane() {
     currentRequestIdRef.current = requestId
     setIsRunning(true)
 
+    const isCancelled = () => currentRequestIdRef.current !== requestId
+
     try {
-      client.runCode(syncFile.content, { requestId, files: [syncFile] })
+      const files = await getLatestProjectFiles()
+      if (isCancelled()) return
+      client.runCode(syncFile.content, { requestId, files })
     } catch (runError) {
-      if (currentRequestIdRef.current !== requestId) {
-        return
-      }
+      if (isCancelled()) return
       currentRequestIdRef.current = null
       setIsRunning(false)
       setError(formatError(runError))
     }
-  }, [buildCurrentDocumentSyncFile, isReady])
+  }, [buildCurrentDocumentSyncFile, getLatestProjectFiles, isReady])
 
   const handleStop = useCallback(() => {
     const client = clientRef.current

+ 210 - 0
services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx

@@ -0,0 +1,210 @@
+import React, { FC, PropsWithChildren } from 'react'
+import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
+import {
+  EditorProviders,
+  projectDefaults,
+} from '../../../helpers/editor-providers'
+import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { ProjectContext } from '@/shared/context/project-context'
+import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
+
+const pythonExecutableScript: Record<string, string> = {
+  file_id: 'test-py-doc-id',
+  filename: 'test.py',
+}
+
+const FileTreePathProvider: FC<PropsWithChildren> = ({ children }) => {
+  return (
+    <FileTreePathContext.Provider
+      value={{
+        pathInFolder: () => pythonExecutableScript.filename,
+        findEntityByPath: () => null,
+        previewByPath: () => null,
+        dirname: () => null,
+      }}
+    >
+      {children}
+    </FileTreePathContext.Provider>
+  )
+}
+
+function makeProjectProvider(fileContents: Record<string, string>) {
+  const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
+    const projectSnapshot = {
+      refresh: async () => {},
+      getDocPaths: () => Object.keys(fileContents),
+      getDocContents: (path: string) => fileContents[path] ?? null,
+    } as unknown as ProjectSnapshot
+
+    return (
+      <ProjectContext.Provider
+        value={{
+          projectId: projectDefaults._id,
+          project: projectDefaults,
+          joinProject: () => {},
+          updateProject: () => {},
+          joinedOnce: true,
+          projectSnapshot,
+          tags: [],
+          features: projectDefaults.features,
+          name: projectDefaults.name,
+        }}
+      >
+        {children}
+      </ProjectContext.Provider>
+    )
+  }
+  return ProjectProvider
+}
+
+describe('<PythonOutputPane />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
+  })
+
+  it('executes a Python script and displays its output', function () {
+    const executablePythonFileContents = "print('hello!')"
+    const projectFiles = {
+      [pythonExecutableScript.filename]: executablePythonFileContents,
+    }
+    const ProjectProvider = makeProjectProvider(projectFiles)
+
+    cy.mount(
+      <EditorProviders
+        scope={{
+          editor: {
+            sharejs_doc: {
+              doc_id: pythonExecutableScript.file_id,
+              getSnapshot: () => executablePythonFileContents,
+            },
+            currentDocumentId: pythonExecutableScript.file_id,
+            openDocName: pythonExecutableScript.filename,
+          },
+        }}
+        providers={{ FileTreePathProvider, ProjectProvider }}
+      >
+        <PythonOutputPane />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Run Python Code' })
+      .should('not.be.disabled')
+      .click()
+    cy.findByText('hello!').should('exist')
+  })
+
+  it('can import and use values from other project Python files', function () {
+    const executablePythonFileContents =
+      'from message import message\nprint(message)'
+
+    const importedPythonFile = {
+      filename: 'message.py',
+      file_contents: "message = 'hello!'",
+    }
+
+    const projectFiles = {
+      [pythonExecutableScript.filename]: executablePythonFileContents,
+      [importedPythonFile.filename]: importedPythonFile.file_contents,
+    }
+    const ProjectProvider = makeProjectProvider(projectFiles)
+
+    cy.mount(
+      <EditorProviders
+        scope={{
+          editor: {
+            sharejs_doc: {
+              doc_id: pythonExecutableScript.file_id,
+              getSnapshot: () => executablePythonFileContents,
+            },
+            currentDocumentId: pythonExecutableScript.file_id,
+            openDocName: pythonExecutableScript.filename,
+          },
+        }}
+        providers={{ FileTreePathProvider, ProjectProvider }}
+      >
+        <PythonOutputPane />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Run Python Code' })
+      .should('not.be.disabled')
+      .click()
+    cy.findByText('hello!').should('exist')
+  })
+
+  it('can import files from different directories relative to the executable script', function () {
+    const executablePythonFileContents = [
+      'from scripts.data_importers.csv_importer import print_data',
+      'print_data()',
+    ].join('\n')
+
+    const csvImporterFile = {
+      filename: 'scripts/data_importers/csv_importer.py',
+      file_contents: [
+        'import csv',
+        '',
+        'def print_data():',
+        '    with open("food_items.csv", "r") as f:',
+        '        reader = csv.reader(f)',
+        '        for row in reader:',
+        '            print(",".join(row))',
+      ].join('\n'),
+    }
+
+    const csvDataFile = {
+      filename: 'food_items.csv',
+      file_contents: 'name,type\nPizza,Italian\nSushi,Japanese\nTacos,Mexican',
+    }
+
+    const projectFiles = {
+      'scripts/test.py': executablePythonFileContents,
+      [csvImporterFile.filename]: csvImporterFile.file_contents,
+      [csvDataFile.filename]: csvDataFile.file_contents,
+    }
+    const ProjectProvider = makeProjectProvider(projectFiles)
+
+    const NestedFileTreePathProvider: FC<PropsWithChildren> = ({
+      children,
+    }) => (
+      <FileTreePathContext.Provider
+        value={{
+          pathInFolder: () => 'scripts/test.py',
+          findEntityByPath: () => null,
+          previewByPath: () => null,
+          dirname: () => null,
+        }}
+      >
+        {children}
+      </FileTreePathContext.Provider>
+    )
+
+    cy.mount(
+      <EditorProviders
+        scope={{
+          editor: {
+            sharejs_doc: {
+              doc_id: pythonExecutableScript.file_id,
+              getSnapshot: () => executablePythonFileContents,
+            },
+            currentDocumentId: pythonExecutableScript.file_id,
+            openDocName: 'test.py',
+          },
+        }}
+        providers={{
+          FileTreePathProvider: NestedFileTreePathProvider,
+          ProjectProvider,
+        }}
+      >
+        <PythonOutputPane />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Run Python Code' })
+      .should('not.be.disabled')
+      .click()
+    cy.findByText('name,type').should('exist')
+    cy.findByText('Pizza,Italian').should('exist')
+    cy.findByText('Sushi,Japanese').should('exist')
+    cy.findByText('Tacos,Mexican').should('exist')
+  })
+})