Skip to content
Open
6 changes: 6 additions & 0 deletions bin/k8s/templates/base/gateway/gateway-routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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
Expand Down
6 changes: 6 additions & 0 deletions bin/single-node/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}

location /api/workflow-to-python {
Comment thread
kz930 marked this conversation as resolved.
proxy_pass http://workflow-compiling-service:9090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

location /api/dataset {
proxy_pass http://file-service:9092;
proxy_set_header Host $host;
Expand Down
5 changes: 5 additions & 0 deletions frontend/proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
}),
}
);
}
}
27 changes: 27 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>
25 changes: 25 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,31 @@ 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;
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import { USER_WORKFLOW } 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";

describe("MenuComponent", () => {
Expand All @@ -70,8 +72,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: [
Expand All @@ -88,6 +92,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();
Expand Down Expand Up @@ -240,6 +249,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;
Expand Down Expand Up @@ -435,6 +456,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);
Expand Down Expand Up @@ -525,6 +593,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;
Expand Down Expand Up @@ -694,6 +775,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();
});

describe("version history", () => {
it("onClickGetAllVersions delegates to workflowVersionService.displayWorkflowVersions", () => {
const displaySpy = vi.spyOn(workflowVersionService, "displayWorkflowVersions").mockImplementation(() => {});
Expand Down Expand Up @@ -1612,6 +1706,27 @@ 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("deactivates the export button unless the feature is on and results exist", () => {
const guiConfig = TestBed.inject(GuiConfigService);
const results$ = component.workflowResultExportService.hasResultToExportOnAllOperators;
Expand Down
Loading
Loading