Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
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>
31 changes: 31 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,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
Expand Down
141 changes: 141 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 @@ -57,6 +57,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";
Expand All @@ -75,8 +77,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 @@ -93,6 +97,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 @@ -321,6 +330,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 @@ -552,6 +573,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 @@ -642,6 +710,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 @@ -856,6 +937,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.
Expand Down Expand Up @@ -1809,6 +1903,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;
Expand Down
Loading
Loading