This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 40ced5bdb399d58aaa0535f5dc886be44a5f64e4
Author: Kary Zheng <[email protected]>
AuthorDate: Thu Oct 15 01:26:57 2026 +0000

    feat(gui): export a workflow as Python, and read the schema's constraints 
in the panel (#8350)
    
    ### What changes were proposed in this PR?
    
    The browser side of the export.
    
    A button on the workflow toolbar asks the compiling service to translate
    the canvas into a Python script and shows what comes back, with a copy
    control so the script can be taken somewhere that runs it. The
    translation is a round trip, so the button disables itself while one is
    in flight, and a workflow the translator cannot express comes back with
    a reason rather than an empty modal. The route is added in the two
    places a browser reaches the compiling service from: the dev server's
    proxy and the single-node nginx.
    
    A workflow that fails to compile cannot be executed either, so the run
    button reads the compilation state alongside the schema and port
    validation it already read. Running to one operator is offered only when
    everything upstream of it compiled too, not only the operator itself.
    
    ### Any related issues, documentation, discussions?
    
    Part of #8325, 25 of 27; that issue lists the set in order.
    
    The property panel's reading of the schema's constraints is in #8507.
    This one stands alone. It touches no Scala, so it compiles and its tests
    run on their own, and its CI is green today. The endpoint the button
    calls is added in #8327: until that lands, clicking it reports that the
    request failed and nothing else changes, so the order here is a
    preference rather than a constraint.
    
    Closes #8427, the task this change is the whole of.
    
    ### What it looks like
    
    The toolbar without this change, and with it. The new control is the
    sixth, between the download and the info buttons.
    
    <img width="549" height="48" alt="toolbar before"
    
src="https://github.com/user-attachments/assets/434cd8d5-16ca-4ff1-ad4f-7244656110c3";
    />
    
    <img width="549" height="48" alt="toolbar after"
    
src="https://github.com/user-attachments/assets/f2d3c617-71fa-44f6-ac7f-9cd854ebe36f";
    />
    
    Clicking it asks the compiling service to translate the canvas and shows
    what comes back, with a control that copies the script.
    
    <img width="1354" height="896" alt="the export modal"
    
src="https://github.com/user-attachments/assets/0b119b15-5ca9-43e8-afd1-098f5fa34f8b";
    />
    
    ### How was this PR tested?
    
    `yarn --cwd frontend run test:ci`, which is what CI runs; a single spec
    is `yarn --cwd frontend ng test --watch=false --include=<path>`.
    
    `menu.component.spec.ts` covers the three ways the export click can end:
    the script coming back, the translator refusing the plan, and the
    request failing. `context-menu.component.spec.ts` covers the
    execute-to-here entry under a failed compilation.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 bin/k8s/templates/base/gateway/gateway-routes.yaml |   6 +
 bin/single-node/nginx.conf                         |   6 +
 frontend/proxy.config.json                         |   5 +
 .../workflow-to-python.service.ts                  |  58 +++++++++
 .../workspace/component/menu/menu.component.html   |  27 ++++
 .../workspace/component/menu/menu.component.scss   |  31 +++++
 .../component/menu/menu.component.spec.ts          | 141 +++++++++++++++++++++
 .../app/workspace/component/menu/menu.component.ts | 106 ++++++++++++++--
 .../context-menu/context-menu.component.spec.ts    |  40 +++++-
 .../context-menu/context-menu.component.ts         |  19 ++-
 10 files changed, 422 insertions(+), 17 deletions(-)

diff --git a/bin/k8s/templates/base/gateway/gateway-routes.yaml 
b/bin/k8s/templates/base/gateway/gateway-routes.yaml
index 5c6ccd79be..7fc5d1f635 100644
--- a/bin/k8s/templates/base/gateway/gateway-routes.yaml
+++ b/bin/k8s/templates/base/gateway/gateway-routes.yaml
@@ -48,6 +48,12 @@ spec:
         - path:
             type: PathPrefix
             value: /api/compile
+        # The Python export reads the same compiled workflow the compile 
endpoint does, so it
+        # is served by the same service. Without a rule of its own it falls to 
the /api
+        # catch-all below and reaches the webserver, which has no such 
resource.
+        - path:
+            type: PathPrefix
+            value: /api/workflow-to-python
       backendRefs:
         - name: workflow-compiling-service-svc
           port: 9090
diff --git a/bin/single-node/nginx.conf b/bin/single-node/nginx.conf
index 250e4cfcb0..4277913998 100644
--- a/bin/single-node/nginx.conf
+++ b/bin/single-node/nginx.conf
@@ -33,6 +33,12 @@ http {
             proxy_set_header X-Real-IP $remote_addr;
         }
 
+        location /api/workflow-to-python {
+            proxy_pass http://workflow-compiling-service:9090;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+        }
+
         location /api/dataset {
             client_max_body_size 0;
             proxy_request_buffering off;
diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json
index 483b7a308a..0e3a0b66dc 100755
--- a/frontend/proxy.config.json
+++ b/frontend/proxy.config.json
@@ -30,6 +30,11 @@
     "secure": false,
     "changeOrigin": true
   },
+  "/api/workflow-to-python": {
+    "target": "http://localhost:9090";,
+    "secure": false,
+    "changeOrigin": true
+  },
   "/api/dataset": {
     "target": "http://localhost:9092";,
     "secure": false,
diff --git 
a/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts
 
b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts
new file mode 100644
index 0000000000..301515d956
--- /dev/null
+++ 
b/frontend/src/app/dashboard/service/user/workflow-to-python/workflow-to-python.service.ts
@@ -0,0 +1,58 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient, HttpHeaders } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+import { LogicalPlan } from 
"../../../../workspace/types/execute-workflow.interface";
+
+export const WORKFLOW_TO_PYTHON_ENDPOINT = "workflow-to-python";
+
+export interface WorkflowToPythonResponse {
+  type: "success" | "failure";
+  pythonCode?: string;
+  errorMessage?: string;
+}
+
+@Injectable({
+  providedIn: "root",
+})
+export class WorkflowToPythonService {
+  constructor(private httpClient: HttpClient) {}
+
+  public convertToPython(logicalPlan: LogicalPlan): 
Observable<WorkflowToPythonResponse> {
+    const body = {
+      operators: logicalPlan.operators,
+      links: logicalPlan.links,
+      opsToReuseResult: [],
+      opsToViewResult: [],
+    };
+
+    return this.httpClient.post<WorkflowToPythonResponse>(
+      `${AppSettings.getApiEndpoint()}/${WORKFLOW_TO_PYTHON_ENDPOINT}`,
+      body,
+      {
+        headers: new HttpHeaders({
+          "Content-Type": "application/json",
+        }),
+      }
+    );
+  }
+}
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html 
b/frontend/src/app/workspace/component/menu/menu.component.html
index 982a37e191..c5bde9d41d 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -153,6 +153,15 @@
             nz-icon
             nzType="download"></i>
         </button>
+        <button
+          (click)="onClickExportAsPython()"
+          [disabled]="isTranslatingToPython || isWorkflowEmpty || 
!isWorkflowValid"
+          nz-button
+          title="export as Python script">
+          <i
+            nz-icon
+            nzType="code"></i>
+        </button>
         <button
           (click)="onClickEditDescription()"
           nz-button
@@ -511,3 +520,21 @@
     </div>
   </div>
 </div>
+
+<ng-template #workflowPythonScriptModal>
+  <div class="workflow-python-modal">
+    <div class="workflow-python-modal-toolbar">
+      <button
+        nz-button
+        title="copy Python script"
+        aria-label="copy Python script"
+        (click)="copyPythonCodeToClipboard()"
+        [disabled]="!pythonCodeForModal">
+        <i
+          nz-icon
+          nzType="copy"></i>
+      </button>
+    </div>
+    <pre class="workflow-python-code">{{ pythonCodeForModal }}</pre>
+  </div>
+</ng-template>
diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss 
b/frontend/src/app/workspace/component/menu/menu.component.scss
index a2ee69762b..8b2bec4d2b 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.scss
+++ b/frontend/src/app/workspace/component/menu/menu.component.scss
@@ -211,6 +211,37 @@ texera-coeditor-user-icon {
   }
 }
 
+.workflow-python-modal {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.workflow-python-modal-toolbar {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.workflow-python-code {
+  max-height: 70vh;
+  overflow: auto;
+  margin: 0;
+  padding: 12px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: #f6f8fa;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.jupyter-notebook-icon {
+  height: 1.1em;
+  width: auto;
+  vertical-align: -0.2em;
+}
+
 /* One workflow, two ways of working on it. Rendered identically in the 
operator canvas
    and the Form View, in the same slot of the same title row, so the control
    never moves when the view does -- that stillness is what makes the two read 
as two
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts 
b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index 085d61b1ae..3a5f8440b8 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -58,6 +58,8 @@ import { USER_WORKFLOW, workspaceFormUrl } from 
"../../../app-routing.constant";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { MockGuiConfigService } from 
"../../../common/service/gui-config.service.mock";
 import { JupyterPanelService } from 
"../../service/jupyter-panel/jupyter-panel.service";
+import { WorkflowCompilingService } from 
"../../service/compile-workflow/workflow-compiling.service";
+import { CompilationState } from "../../types/workflow-compiling.interface";
 import type { Mocked } from "vitest";
 import { WarehouseService } from 
"../../../common/service/warehouse/warehouse.service";
 import { Privilege } from "../../../dashboard/type/share-access.interface";
@@ -76,8 +78,10 @@ describe("MenuComponent", () => {
   let notificationService: NotificationService;
   let location: Location;
   let validationStream$: BehaviorSubject<ValidationOutput>;
+  let compilationStream$: Subject<CompilationState>;
 
   beforeEach(async () => {
+    compilationStream$ = new Subject<CompilationState>();
     await TestBed.configureTestingModule({
       imports: [MenuComponent, HttpClientTestingModule, 
RouterTestingModule.withRoutes([]), NzModalModule],
       providers: [
@@ -94,6 +98,11 @@ describe("MenuComponent", () => {
           },
         },
         { provide: UserService, useClass: StubUserService },
+        {
+          // stubbed so the debounced compile request of the real service does 
not outlive the test injector
+          provide: WorkflowCompilingService,
+          useValue: { getCompilationStateInfoChangedStream: () => 
compilationStream$.asObservable() },
+        },
         ...commonTestProviders,
       ],
     }).compileComponents();
@@ -405,6 +414,18 @@ describe("MenuComponent", () => {
       expect(behavior.disable).toBe(true);
     });
 
+    it("returns 'Invalid Workflow' when the workflow does not compile", () => {
+      component.isWorkflowValid = true;
+      component.isWorkflowEmpty = false;
+      component.isWorkflowCompilable = false;
+
+      const behavior = component.getRunButtonBehavior();
+
+      expect(behavior.text).toBe("Invalid Workflow");
+      expect(behavior.icon).toBe("warning");
+      expect(behavior.disable).toBe(true);
+    });
+
     it("returns 'Empty Workflow' when the workflow has no operators", () => {
       component.isWorkflowValid = true;
       component.isWorkflowEmpty = true;
@@ -696,6 +717,53 @@ describe("MenuComponent", () => {
     expect(component.runDisable).toBe(true);
   });
 
+  describe("export as Python", () => {
+    // Exporting the valid part of the graph answers a workflow with a required
+    // value missing by leaving that operator and its links out, and handing 
back
+    // a script that runs and is not the workflow.
+    it("refuses a workflow the canvas reports errors on", () => {
+      const errorSpy = vi.spyOn(notificationService, 
"error").mockImplementation(() => {});
+      const convert = vi.spyOn(component["workflowToPythonService"], 
"convertToPython");
+      validationStream$.next({
+        errors: { "operator-1": { isValid: false, messages: { attribute: 
"required" } } } as any,
+        workflowEmpty: false,
+      });
+
+      component.onClickExportAsPython();
+
+      expect(convert).not.toHaveBeenCalled();
+      expect(errorSpy).toHaveBeenCalled();
+      expect(component.isTranslatingToPython).toBe(false);
+    });
+
+    it("refuses an empty workflow", () => {
+      vi.spyOn(notificationService, "error").mockImplementation(() => {});
+      const convert = vi.spyOn(component["workflowToPythonService"], 
"convertToPython");
+      validationStream$.next({ errors: {}, workflowEmpty: true });
+
+      component.onClickExportAsPython();
+
+      expect(convert).not.toHaveBeenCalled();
+    });
+
+    // The whole graph, not the valid part of it: the two agree once the 
workflow
+    // is valid, and asking for the valid part is what dropped operators.
+    it("sends the whole graph once the workflow is valid", () => {
+      const convert = vi
+        .spyOn(component["workflowToPythonService"], "convertToPython")
+        .mockReturnValue(of({ type: "success", pythonCode: "print(1)" } as 
any));
+      const validSubgraph = vi.spyOn(validationWorkflowService, 
"getValidTexeraGraph");
+      workflowActionService.addOperator(mockScanPredicate, mockPoint);
+      validationStream$.next({ errors: {}, workflowEmpty: false });
+
+      component.onClickExportAsPython();
+
+      expect(validSubgraph).not.toHaveBeenCalled();
+      expect(convert).toHaveBeenCalled();
+      expect(convert.mock.calls[0][0].operators).toHaveLength(1);
+    });
+  });
+
   describe("hasOperators", () => {
     it("returns false on an empty graph", () => {
       expect(component.hasOperators()).toBe(false);
@@ -786,6 +854,19 @@ describe("MenuComponent", () => {
       
expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).not.toHaveBeenCalled();
     });
 
+    it("does nothing when the workflow does not compile", () => {
+      component.isWorkflowValid = true;
+      component.isWorkflowEmpty = false;
+      component.isWorkflowCompilable = false;
+      component.computingUnitStatus = ComputingUnitState.Running;
+      const executeSpy = vi.spyOn(executeWorkflowService, 
"executeWorkflowWithEmailNotification");
+
+      component.runWorkflow();
+
+      expect(executeSpy).not.toHaveBeenCalled();
+      
expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).not.toHaveBeenCalled();
+    });
+
     it("does nothing when the workflow is empty", () => {
       component.isWorkflowValid = true;
       component.isWorkflowEmpty = true;
@@ -1000,6 +1081,19 @@ describe("MenuComponent", () => {
     });
   });
 
+  it("copyPythonCodeToClipboard writes the generated Python script to the 
clipboard", async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    vi.stubGlobal("navigator", { clipboard: { writeText } });
+    const successSpy = vi.spyOn(notificationService, 
"success").mockImplementation(() => {});
+    component.pythonCodeForModal = "print('hello')";
+
+    await component.copyPythonCodeToClipboard();
+
+    expect(writeText).toHaveBeenCalledWith("print('hello')");
+    expect(successSpy).toHaveBeenCalledWith("Python script copied to 
clipboard");
+    vi.unstubAllGlobals();
+  });
+
   // The export flags are reset when a menu is destroyed -- right on leaving 
the workspace, wrong on
   // a hand-over between a workflow's two views, where the results are kept. A 
menu mounting on
   // retained results asks for them to be recomputed rather than offering a 
dead button.
@@ -1953,6 +2047,53 @@ describe("MenuComponent", () => {
       }
     });
 
+    it("re-applies the run button behavior on every compilation state event", 
() => {
+      component.isWorkflowValid = true;
+      component.isWorkflowEmpty = false;
+      component.computingUnitStatus = ComputingUnitState.Running;
+      component.executionState = ExecutionState.Uninitialized;
+      Object.defineProperty(component.workflowWebsocketService, "isConnected", 
{
+        get: () => true,
+        configurable: true,
+      });
+
+      compilationStream$.next(CompilationState.Failed);
+      expect(component.isWorkflowCompilable).toBe(false);
+      expect(component.runButtonText).toBe("Invalid Workflow");
+      expect(component.runDisable).toBe(true);
+
+      compilationStream$.next(CompilationState.Succeeded);
+      expect(component.isWorkflowCompilable).toBe(true);
+      expect(component.runButtonText).toBe("Run");
+      expect(component.runDisable).toBe(false);
+    });
+
+    it("keeps Pause and Resume when a compile fails after the run started", () 
=> {
+      component.isWorkflowValid = true;
+      component.isWorkflowEmpty = false;
+      component.computingUnitStatus = ComputingUnitState.Running;
+      Object.defineProperty(component.workflowWebsocketService, "isConnected", 
{
+        get: () => true,
+        configurable: true,
+      });
+
+      component.executionState = ExecutionState.Running;
+      compilationStream$.next(CompilationState.Failed);
+      expect(component.runButtonText).toBe("Pause");
+      expect(component.runDisable).toBe(false);
+
+      component.executionState = ExecutionState.Paused;
+      compilationStream$.next(CompilationState.Failed);
+      expect(component.runButtonText).toBe("Resume");
+      expect(component.runDisable).toBe(false);
+
+      // the failure still blocks the next run once this one ends
+      component.executionState = ExecutionState.Completed;
+      compilationStream$.next(CompilationState.Failed);
+      expect(component.runButtonText).toBe("Invalid Workflow");
+      expect(component.runDisable).toBe(true);
+    });
+
     it("deactivates the export button unless the feature is on and results 
exist", () => {
       const guiConfig = TestBed.inject(GuiConfigService);
       const results$ = 
component.workflowResultExportService.hasResultToExportOnAllOperators;
diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts 
b/frontend/src/app/workspace/component/menu/menu.component.ts
index f04afd8bfd..b81eb7a953 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -18,7 +18,7 @@
  */
 
 import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet, AsyncPipe } from 
"@angular/common";
-import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from 
"@angular/core";
+import { Component, ElementRef, Input, OnDestroy, OnInit, TemplateRef, 
ViewChild } from "@angular/core";
 import { Router, RouterLink } from "@angular/router";
 import { UserService } from "../../../common/service/user/user.service";
 import { WorkflowPersistService } from 
"../../../common/service/workflow-persist/workflow-persist.service";
@@ -44,6 +44,7 @@ import { firstValueFrom, of } from "rxjs";
 import { NzModalService } from "ng-zorro-antd/modal";
 import { ResultExportationComponent } from 
"../result-exportation/result-exportation.component";
 import { ReportGenerationService } from 
"../../service/report-generation/report-generation.service";
+import { WorkflowToPythonService } from 
"../../../dashboard/service/user/workflow-to-python/workflow-to-python.service";
 import { ShareAccessComponent } from 
"src/app/dashboard/component/user/share-access/share-access.component";
 import { PanelService } from "../../service/panel/panel.service";
 import { USER_WORKFLOW, workspaceFormUrl } from 
"../../../app-routing.constant";
@@ -74,6 +75,8 @@ import { NzSwitchComponent } from "ng-zorro-antd/switch";
 import { NzBadgeComponent } from "ng-zorro-antd/badge";
 import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
 import { JupyterPanelService } from 
"../../service/jupyter-panel/jupyter-panel.service";
+import { WorkflowCompilingService } from 
"../../service/compile-workflow/workflow-compiling.service";
+import { CompilationState } from "../../types/workflow-compiling.interface";
 
 /**
  * MenuComponent is the top level menu bar that shows
@@ -141,6 +144,9 @@ export class MenuComponent implements OnInit, OnDestroy {
   public ComputingUnitState = ComputingUnitState; // make Angular HTML access 
enum definition
   public isWorkflowValid: boolean = true; // this will check whether the 
workflow error or not
   public isWorkflowEmpty: boolean = false;
+  // whether the last compilation of the workflow failed. A workflow that 
cannot compile cannot be executed,
+  // so it is treated the same way as one that fails the schema / port 
validation.
+  public isWorkflowCompilable: boolean = true;
   public isSaving: boolean = false;
   /** A Form View hand-over is in progress (saving, then the route); a second 
click is a no-op. */
   private handingOverToFormView = false;
@@ -164,6 +170,7 @@ export class MenuComponent implements OnInit, OnDestroy {
   @Input() public currentExecutionName: string = ""; // reset executionName
   @Input() public particularVersionDate: string = ""; // placeholder for the 
metadata information of a particular workflow version
   @ViewChild("workflowNameInput") workflowNameInput: 
ElementRef<HTMLInputElement> | undefined;
+  @ViewChild("workflowPythonScriptModal", { static: true }) 
workflowPythonScriptModal?: TemplateRef<void>;
 
   // variable bound with HTML to decide if the running spinner should show
   public runButtonText = "Run";
@@ -189,6 +196,7 @@ export class MenuComponent implements OnInit, OnDestroy {
     private location: Location,
     public undoRedoService: UndoRedoService,
     public validationWorkflowService: ValidationWorkflowService,
+    private workflowCompilingService: WorkflowCompilingService,
     public workflowPersistService: WorkflowPersistService,
     public workflowVersionService: WorkflowVersionService,
     public userService: UserService,
@@ -205,6 +213,7 @@ export class MenuComponent implements OnInit, OnDestroy {
     private warehouseService: WarehouseService,
     protected config: GuiConfigService,
     private router: Router,
+    private workflowToPythonService: WorkflowToPythonService,
     private jupyterPanelService: JupyterPanelService,
     private fileSaverService: FileSaverService
   ) {
@@ -263,6 +272,16 @@ export class MenuComponent implements OnInit, OnDestroy {
         this.applyRunButtonBehavior(this.getRunButtonBehavior());
       });
 
+    // the compilation errors are reported per operator on the canvas and in 
the result panel, but a workflow
+    // that cannot compile cannot be executed either, so the run button has to 
reflect the compilation state too
+    this.workflowCompilingService
+      .getCompilationStateInfoChangedStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(state => {
+        this.isWorkflowCompilable = state !== CompilationState.Failed;
+        this.applyRunButtonBehavior(this.getRunButtonBehavior());
+      });
+
     // Subscribe to WorkflowResultExportService observable
     this.workflowResultExportService
       .getExportOnAllOperatorsStatusStream()
@@ -397,14 +416,27 @@ export class MenuComponent implements OnInit, OnDestroy {
     this.onClickRunHandler = behavior.onClick;
   }
 
+  // the execution states in which the run button starts a new run rather than 
controlling one
+  private buttonStartsRun(): boolean {
+    return [
+      ExecutionState.Uninitialized,
+      ExecutionState.Completed,
+      ExecutionState.Terminated,
+      ExecutionState.Killed,
+      ExecutionState.Failed,
+    ].includes(this.executionState);
+  }
+
   public getRunButtonBehavior(): {
     text: string;
     icon: string;
     disable: boolean;
     onClick: () => void;
   } {
-    // If workflow is invalid, always disable and show "Invalid Workflow"
-    if (!this.isWorkflowValid) {
+    // If workflow is invalid, always disable and show "Invalid Workflow". A 
failed compile
+    // does the same, but only where the button would start a run: a compile 
can answer
+    // after the run started, and it must not take Pause/Resume away from that 
run.
+    if (!this.isWorkflowValid || (!this.isWorkflowCompilable && 
this.buttonStartsRun())) {
       return {
         text: "Invalid Workflow",
         icon: "warning",
@@ -470,16 +502,7 @@ export class MenuComponent implements OnInit, OnDestroy {
     // the click into the create-warehouse modal. Only in the states whose
     // button would start a run: mid-execution the button is Pause/Resume/Kill,
     // and losing the last warehouse must not take that control away.
-    if (
-      this.computingUnitSelectionComponent?.warehouseRequiredButMissing &&
-      [
-        ExecutionState.Uninitialized,
-        ExecutionState.Completed,
-        ExecutionState.Terminated,
-        ExecutionState.Killed,
-        ExecutionState.Failed,
-      ].includes(this.executionState)
-    ) {
+    if (this.computingUnitSelectionComponent?.warehouseRequiredButMissing && 
this.buttonStartsRun()) {
       return {
         text: "Warehouse",
         icon: "plus-circle",
@@ -725,6 +748,61 @@ export class MenuComponent implements OnInit, OnDestroy {
     this.jupyterPanelService.openJupyterNotebookPanel();
   }
 
+  public isTranslatingToPython = false;
+  public pythonCodeForModal = "";
+
+  public onClickExportAsPython(): void {
+    // Exporting the valid part of the graph answers a workflow with a required
+    // value missing by leaving that operator and its links out, and handing 
back
+    // a script that runs and is not the workflow. The button is disabled in 
that
+    // state; this says the same thing for a click that arrives another way.
+    if (this.isWorkflowEmpty || !this.isWorkflowValid) {
+      this.notificationService.error("This workflow cannot be exported yet: 
fix the errors the canvas reports first.");
+      return;
+    }
+
+    // The whole graph, now that it is known to be whole.
+    const logicalPlan = 
ExecuteWorkflowService.getLogicalPlanRequest(this.workflowActionService.getTexeraGraph());
+
+    this.isTranslatingToPython = true;
+    this.workflowToPythonService
+      .convertToPython(logicalPlan)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: response => {
+          this.isTranslatingToPython = false;
+          if (response.type === "success") {
+            this.pythonCodeForModal = response.pythonCode ?? "";
+            this.modalService.create({
+              nzTitle: "Workflow as Python Script",
+              nzContent: this.workflowPythonScriptModal ?? "",
+              nzFooter: null,
+              nzWidth: 800,
+            });
+          } else {
+            this.notificationService.error(response.errorMessage ?? "Failed to 
translate workflow to Python.");
+          }
+        },
+        error: () => {
+          this.isTranslatingToPython = false;
+          this.notificationService.error("Request failed while translating 
workflow to Python.");
+        },
+      });
+  }
+
+  public async copyPythonCodeToClipboard(): Promise<void> {
+    if (!this.pythonCodeForModal) {
+      return;
+    }
+
+    try {
+      await navigator.clipboard.writeText(this.pythonCodeForModal);
+      this.notificationService.success("Python script copied to clipboard");
+    } catch (error) {
+      this.notificationService.error("Failed to copy Python script");
+    }
+  }
+
   public onClickExportWorkflow(): void {
     // The same shape the dashboard download produces (see exportedWorkflow): 
the content plus the
     // landing view as a sibling key, so a file exported here uploads as a 
form-default workflow too.
@@ -1030,7 +1108,7 @@ export class MenuComponent implements OnInit, OnDestroy {
    */
   runWorkflow(): void {
     // Use the existing flags that were already updated via subscriptions
-    if (!this.isWorkflowValid || this.isWorkflowEmpty) {
+    if (!this.isWorkflowValid || !this.isWorkflowCompilable || 
this.isWorkflowEmpty) {
       return;
     }
 
diff --git 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
index f88ef9e5cf..19d8a83f35 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
@@ -33,6 +33,7 @@ import { ReactiveFormsModule } from "@angular/forms";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
 import { NzDropDownModule } from "ng-zorro-antd/dropdown";
 import { ValidationWorkflowService } from 
"src/app/workspace/service/validation/validation-workflow.service";
+import { WorkflowCompilingService } from 
"src/app/workspace/service/compile-workflow/workflow-compiling.service";
 import { NzModalModule, NzModalService } from "ng-zorro-antd/modal";
 import { commonTestProviders } from 
"../../../../../common/testing/test-utils"; // Import NzModalModule and 
NzModalService
 import type { Mocked } from "vitest";
@@ -56,6 +57,7 @@ describe("ContextMenuComponent", () => {
   let operatorMenuService: Mocked<OperatorMenuService>;
   let jointGraphWrapperSpy: Mocked<JointGraphWrapper>;
   let validationWorkflowService: Mocked<ValidationWorkflowService>;
+  let workflowCompilingService: Mocked<WorkflowCompilingService>;
   let highlightedOperatorsSubject: BehaviorSubject<readonly string[]>;
   let highlightedCommentBoxesSubject: BehaviorSubject<readonly string[]>;
 
@@ -71,7 +73,12 @@ describe("ContextMenuComponent", () => {
     
jointGraphWrapperSpy.getCurrentHighlightedCommentBoxIDs.mockReturnValue([]);
     jointGraphWrapperSpy.getCurrentHighlightedLinkIDs.mockReturnValue([]);
 
-    const texeraGraphSpy = { isOperatorDisabled: vi.fn(), hasLinkWithID: 
vi.fn(), bundleActions: vi.fn() };
+    const texeraGraphSpy = {
+      isOperatorDisabled: vi.fn(),
+      hasLinkWithID: vi.fn(),
+      bundleActions: vi.fn(),
+      getSubDAG: vi.fn(),
+    };
 
     const workflowActionServiceSpy = {
       getJointGraphWrapper: vi.fn(),
@@ -92,6 +99,7 @@ describe("ContextMenuComponent", () => {
 
     // Set up TexeraGraph spy return values
     texeraGraphSpy.hasLinkWithID.mockReturnValue(false);
+    texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [], links: [] });
     texeraGraphSpy.bundleActions.mockImplementation((callback: Function) => 
callback());
 
     const workflowResultServiceSpy = { getResultService: vi.fn(), 
hasAnyResult: vi.fn() };
@@ -121,6 +129,9 @@ describe("ContextMenuComponent", () => {
 
     const validationWorkflowServiceSpy = { validateOperator: vi.fn() };
 
+    const workflowCompilingServiceSpy = { getWorkflowCompilationErrors: 
vi.fn() };
+    
workflowCompilingServiceSpy.getWorkflowCompilationErrors.mockReturnValue({});
+
     await TestBed.configureTestingModule({
       providers: [
         { provide: OperatorMetadataService, useClass: 
StubOperatorMetadataService },
@@ -129,6 +140,7 @@ describe("ContextMenuComponent", () => {
         { provide: WorkflowResultExportService, useValue: 
workflowResultExportServiceSpy },
         { provide: OperatorMenuService, useValue: operatorMenuService },
         { provide: ValidationWorkflowService, useValue: 
validationWorkflowServiceSpy },
+        { provide: WorkflowCompilingService, useValue: 
workflowCompilingServiceSpy },
         NzModalService, // Provide NzModalService
         ...commonTestProviders,
       ],
@@ -151,6 +163,7 @@ describe("ContextMenuComponent", () => {
     validationWorkflowService = TestBed.inject(
       ValidationWorkflowService
     ) as unknown as Mocked<ValidationWorkflowService>;
+    workflowCompilingService = TestBed.inject(WorkflowCompilingService) as 
unknown as Mocked<WorkflowCompilingService>;
 
     fixture = TestBed.createComponent(ContextMenuComponent);
     component = fixture.componentInstance;
@@ -263,6 +276,31 @@ describe("ContextMenuComponent", () => {
       expect(texeraGraphSpy.isOperatorDisabled).toHaveBeenCalledWith("op1");
     });
 
+    it("should return false when the target operator failed to compile", () => 
{
+      texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [{ operatorID: 
"op1" }], links: [] } as any);
+      workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ 
op1: {} as any });
+
+      expect(component.canExecuteOperator()).toBe(false);
+      expect(texeraGraphSpy.getSubDAG).toHaveBeenCalledWith("op1");
+    });
+
+    it("should return false when an upstream operator failed to compile", () 
=> {
+      texeraGraphSpy.getSubDAG.mockReturnValue({
+        operators: [{ operatorID: "op1" }, { operatorID: "upstream" }],
+        links: [],
+      } as any);
+      workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ 
upstream: {} as any });
+
+      expect(component.canExecuteOperator()).toBe(false);
+    });
+
+    it("should return true when the compilation error is outside the target's 
sub-DAG", () => {
+      texeraGraphSpy.getSubDAG.mockReturnValue({ operators: [{ operatorID: 
"op1" }], links: [] } as any);
+      workflowCompilingService.getWorkflowCompilationErrors.mockReturnValue({ 
unrelated: {} as any });
+
+      expect(component.canExecuteOperator()).toBe(true);
+    });
+
     it("should check disabled status only for valid operators", () => {
       // First test with invalid operator
       validationWorkflowService.validateOperator.mockReturnValue({ isValid: 
false, messages: {} });
diff --git 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
index 911172b6ba..f7a0888e00 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
@@ -26,6 +26,7 @@ import { WorkflowResultExportService } from 
"src/app/workspace/service/workflow-
 import { NzModalService } from "ng-zorro-antd/modal";
 import { ResultExportationComponent } from 
"../../../result-exportation/result-exportation.component";
 import { ValidationWorkflowService } from 
"src/app/workspace/service/validation/validation-workflow.service";
+import { WorkflowCompilingService } from 
"src/app/workspace/service/compile-workflow/workflow-compiling.service";
 import { GuiConfigService } from 
"../../../../../common/service/gui-config.service";
 import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu";
 import { NgIf } from "@angular/common";
@@ -65,7 +66,8 @@ export class ContextMenuComponent {
     protected config: GuiConfigService,
     private workflowResultService: WorkflowResultService,
     private modalService: NzModalService,
-    private validationWorkflowService: ValidationWorkflowService
+    private validationWorkflowService: ValidationWorkflowService,
+    private workflowCompilingService: WorkflowCompilingService
   ) {
     this.registerWorkflowModifiableChangedHandler();
     this.operatorMenuService.highlightedOperators$
@@ -96,10 +98,23 @@ export class ContextMenuComponent {
   private isOperatorExecutable(operatorID: string): boolean {
     return (
       this.validationWorkflowService.validateOperator(operatorID).isValid &&
-      
!this.workflowActionService.getTexeraGraph().isOperatorDisabled(operatorID)
+      
!this.workflowActionService.getTexeraGraph().isOperatorDisabled(operatorID) &&
+      this.isSubDAGCompilable(operatorID)
     );
   }
 
+  /**
+   * Executing to an operator runs the operator together with everything 
upstream of it, so the entry has to be
+   * disabled when any operator in that sub-DAG failed to compile, not only 
when the target operator itself did.
+   */
+  private isSubDAGCompilable(operatorID: string): boolean {
+    const compilationErrors = 
this.workflowCompilingService.getWorkflowCompilationErrors();
+    return this.workflowActionService
+      .getTexeraGraph()
+      .getSubDAG(operatorID)
+      .operators.every(operator => !(operator.operatorID in 
compilationErrors));
+  }
+
   public hasHighlightedLinks(): boolean {
     return 
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedLinkIDs().length
 > 0;
   }

Reply via email to