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
The following commit(s) were added to refs/heads/main by this push:
new 4afe8d4ecd feat(frontend): add UDF parameters from the panel (#7551)
4afe8d4ecd is described below
commit 4afe8d4ecdf6b4eff007ce55b859b09553163c43
Author: carloea2 <[email protected]>
AuthorDate: Thu Aug 13 18:26:57 2026 +0000
feat(frontend): add UDF parameters from the panel (#7551)
<img width="807" height="175" alt="image"
src="https://github.com/user-attachments/assets/9145a9be-6ab4-458d-834b-057c0686549a"
/>
<img width="589" height="108" alt="image"
src="https://github.com/user-attachments/assets/06e6f5c6-9bd4-4b47-8070-d04d45b6836f"
/>
### What changes were proposed in this PR?
This PR lets users add Python UDF parameters directly from the
properties panel.
The parameter list now includes a dashed **Add parameter** button.
Clicking it opens an inline draft row where the user can enter a name,
select a supported type, and confirm with the check button or Enter, or
cancel with the close button or Escape.
Confirming the draft inserts a `self.UiParameter(...)` declaration into
the operator's shared Python code. The code remains the single source of
truth: the parameter row appears through the existing parse-and-sync
flow instead of being written directly into the form model.
The insertion logic:
- groups new declarations with existing `UiParameter` declarations;
- inserts into an existing `open()` method when available;
- creates `open()` when it is absent;
- handles docstrings, decorators, `pass`, comments, and empty class
bodies;
- converts display names into safe Python assignment targets while
preserving the exact parameter name;
- rejects missing names, duplicate declarations, unsupported types, and
unsupported UDF code shapes.
<!-- Add before/after properties-panel screenshots here before marking
the PR ready. -->
### Any related issues, documentation, discussions?
Closes #7552
### How was this PR tested?
Manual verification by the author covered the add, confirm/cancel,
declaration insertion, and properties-panel synchronization flows.
Frontend CI formatting and lint:
```bash
cd frontend
yarn format:ci
```
The command passes.
The updated specifications cover component actions and error states,
shared-code insertion and synchronization, declaration placement,
identifier sanitization, duplicate names, unsupported types, unsupported
classes, and malformed class bodies.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code Fable 5 Ultra, OpenAI Codex 5.6 Ultra, and Me
---
.../ui-udf-parameters.component.html | 63 ++++++-
.../ui-udf-parameters.component.scss | 28 +++
.../ui-udf-parameters.component.spec.ts | 85 ++++++++-
.../ui-udf-parameters.component.ts | 49 ++++-
.../ui-udf-parameters-parser.service.spec.ts | 187 ++++++++++++++++++
.../ui-udf-parameters-parser.service.ts | 209 +++++++++++++++++----
.../ui-udf-parameters-sync.service.spec.ts | 45 ++++-
.../code-editor/ui-udf-parameters-sync.service.ts | 32 +++-
8 files changed, 649 insertions(+), 49 deletions(-)
diff --git
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.html
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.html
index 26ea63b827..e8220aa973 100644
---
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.html
+++
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.html
@@ -17,9 +17,23 @@
under the License.
-->
<div class="ui-udf-parameters-field">
+ <button
+ class="add-parameter-button"
+ *ngIf="workflowModificationEnabled && !draftVisible"
+ nz-button
+ nzType="dashed"
+ nzBlock
+ type="button"
+ (click)="draftVisible = true">
+ <span
+ nz-icon
+ nzType="plus"></span>
+ Add parameter
+ </button>
+
<div
class="ui-udf-parameter-list"
- *ngIf="model?.length">
+ *ngIf="model?.length || draftVisible">
<div class="ui-udf-parameter-row header">
<div
class="field-cell"
@@ -28,6 +42,53 @@
</div>
</div>
+ <div
+ class="ui-udf-parameter-row draft"
+ *ngIf="draftVisible">
+ <div class="field-cell"></div>
+ <div class="field-cell">
+ <input
+ #parameterNameInput
+ type="text"
+ placeholder="Parameter name"
+ autofocus
+ (keyup.enter)="addParameter(parameterNameInput,
parameterTypeSelect.value)"
+ (keyup.escape)="draftVisible = false" />
+ </div>
+ <div class="field-cell draft-actions">
+ <select #parameterTypeSelect>
+ <option
+ *ngFor="let parameterType of addParameterTypeOptions"
+ [value]="parameterType">
+ {{ parameterType }}
+ </option>
+ </select>
+ <button
+ nz-button
+ nzType="primary"
+ nzShape="circle"
+ nzSize="small"
+ type="button"
+ title="Add parameter"
+ (click)="addParameter(parameterNameInput,
parameterTypeSelect.value)">
+ <span
+ nz-icon
+ nzType="check"></span>
+ </button>
+ <button
+ nz-button
+ nzShape="circle"
+ nzSize="small"
+ type="button"
+ title="Cancel"
+ (click)="draftVisible = false">
+ <span
+ nz-icon
+ nzType="close"></span>
+ </button>
+ </div>
+ </div>
+
<div
class="ui-udf-parameter-row"
*ngFor="let parameter of (model || []); let i = index; trackBy:
trackByParameterName">
diff --git
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.scss
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.scss
index ab6b2ad6ee..a4ccd6039f 100644
---
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.scss
+++
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.scss
@@ -35,3 +35,31 @@
:host ::ng-deep .ant-form-item-label {
display: none;
}
+
+.ui-udf-parameter-row.draft {
+ align-items: center;
+
+ input,
+ select {
+ width: 100%;
+ height: 32px;
+ padding: 0 11px;
+ border: 1px solid #d9d9d9;
+ border-radius: 2px;
+ background: transparent;
+ }
+
+ .draft-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+
+ select {
+ flex: 1;
+ }
+ }
+}
+
+.add-parameter-button {
+ margin-bottom: 8px;
+}
diff --git
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.spec.ts
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.spec.ts
index 74b876d321..6813293bc3 100644
---
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.spec.ts
+++
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.spec.ts
@@ -18,14 +18,71 @@
*/
import { FormControl } from "@angular/forms";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormlyFieldConfig } from "@ngx-formly/core";
+import type { Mock } from "vitest";
+import { vi as vitest } from "vitest";
+import { NotificationService } from
"../../../common/service/notification/notification.service";
+import { UiUdfParametersEditError } from
"../../service/code-editor/ui-udf-parameters-parser.service";
+import { UiUdfParametersSyncService } from
"../../service/code-editor/ui-udf-parameters-sync.service";
+import { WorkflowActionService } from
"../../service/workflow-graph/model/workflow-action.service";
import { UiUdfParametersComponent } from "./ui-udf-parameters.component";
describe("UiUdfParametersComponent", () => {
+ const operatorId = "operator-1";
+
+ let fixture: ComponentFixture<UiUdfParametersComponent>;
let component: UiUdfParametersComponent;
+ let workflowActionServiceMock: {
+ checkWorkflowModificationEnabled: Mock;
+ getJointGraphWrapper: Mock;
+ };
+ let syncServiceMock: { addParameter: Mock };
+ let notificationServiceMock: { error: Mock };
+
+ beforeEach(async () => {
+ workflowActionServiceMock = {
+ checkWorkflowModificationEnabled: vitest.fn().mockReturnValue(true),
+ getJointGraphWrapper: vitest.fn().mockReturnValue({
+ getCurrentHighlightedOperatorIDs: () => [operatorId],
+ }),
+ };
+ syncServiceMock = { addParameter: vitest.fn() };
+ notificationServiceMock = { error: vitest.fn() };
+
+ await TestBed.configureTestingModule({
+ imports: [UiUdfParametersComponent],
+ providers: [
+ { provide: WorkflowActionService, useValue: workflowActionServiceMock
},
+ { provide: UiUdfParametersSyncService, useValue: syncServiceMock },
+ { provide: NotificationService, useValue: notificationServiceMock },
+ ],
+ }).compileComponents();
- beforeEach(() => {
- component = new UiUdfParametersComponent();
+ fixture = TestBed.createComponent(UiUdfParametersComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("should render the add control and draft row before existing parameters",
() => {
+ (component as any).field = {
+ model: [{ value: "42", attribute: { attributeName: "threshold",
attributeType: "double" } }],
+ fieldGroup: [{}],
+ } as FormlyFieldConfig;
+
+ fixture.detectChanges();
+
+ const addButton =
fixture.nativeElement.querySelector(".add-parameter-button") as HTMLElement;
+ const parameterList =
fixture.nativeElement.querySelector(".ui-udf-parameter-list") as HTMLElement;
+ expect(addButton.compareDocumentPosition(parameterList) &
Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+
+ component.draftVisible = true;
+ fixture.detectChanges();
+
+ const draftRow =
fixture.nativeElement.querySelector(".ui-udf-parameter-row.draft") as
HTMLElement;
+ const existingRow = fixture.nativeElement.querySelector(
+ ".ui-udf-parameter-row:not(.header):not(.draft)"
+ ) as HTMLElement;
+ expect(draftRow.compareDocumentPosition(existingRow) &
Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("should disable name and type fields while leaving value editable", () =>
{
@@ -97,6 +154,30 @@ describe("UiUdfParametersComponent", () => {
expect(control.disabled).toBe(column.disabled);
});
});
+
+ it("should add a parameter for the highlighted operator and close the draft
row", () => {
+ component.draftVisible = true;
+
+ component.addParameter({ value: "threshold" } as HTMLInputElement,
"double");
+
+ expect(syncServiceMock.addParameter).toHaveBeenCalledWith(operatorId,
"threshold", "double");
+ expect(component.draftVisible).toBe(false);
+ expect(notificationServiceMock.error).not.toHaveBeenCalled();
+ });
+
+ it("should surface edit errors and keep the draft row open", () => {
+ component.draftVisible = true;
+ syncServiceMock.addParameter.mockImplementation(() => {
+ throw new UiUdfParametersEditError("UiParameter name 'threshold' is
declared already.");
+ });
+
+ component.addParameter({ value: "threshold" } as HTMLInputElement,
"double");
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith(
+ "Could not add UDF parameter: UiParameter name 'threshold' is declared
already."
+ );
+ expect(component.draftVisible).toBe(true);
+ });
});
function rowConfig(fields: ReadonlyArray<{ key: string; formControl?:
FormControl }>): FormlyFieldConfig {
diff --git
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.ts
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.ts
index d725004c58..d1aebb0cb9 100644
---
a/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.ts
+++
b/frontend/src/app/workspace/component/ui-udf-parameters/ui-udf-parameters.component.ts
@@ -19,6 +19,18 @@
import { Component } from "@angular/core";
import { NgFor, NgIf } from "@angular/common";
import { FieldArrayType, FormlyFieldConfig, FormlyModule } from
"@ngx-formly/core";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { ɵNzTransitionPatchDirective } from
"ng-zorro-antd/core/transition-patch";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NotificationService } from
"../../../common/service/notification/notification.service";
+import { WorkflowActionService } from
"../../service/workflow-graph/model/workflow-action.service";
+import {
+ UiUdfParametersEditError,
+ UiUdfParametersParseError,
+} from "../../service/code-editor/ui-udf-parameters-parser.service";
+import { UiUdfParametersSyncService } from
"../../service/code-editor/ui-udf-parameters-sync.service";
+import type { AttributeType } from "../../types/workflow-compiling.interface";
type UiUdfParameterColumn = Readonly<{ label: string; key: string; parentKey?:
string; disabled: boolean }>;
@@ -27,7 +39,15 @@ type UiUdfParameterColumn = Readonly<{ label: string; key:
string; parentKey?: s
selector: "texera-ui-udf-parameters",
templateUrl: "./ui-udf-parameters.component.html",
styleUrls: ["./ui-udf-parameters.component.scss"],
- imports: [NgIf, NgFor, FormlyModule],
+ imports: [
+ NgIf,
+ NgFor,
+ FormlyModule,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ ],
})
export class UiUdfParametersComponent extends
FieldArrayType<FormlyFieldConfig> {
private readonly disabledStateConfigured = new WeakMap<FormlyFieldConfig,
boolean>();
@@ -38,6 +58,33 @@ export class UiUdfParametersComponent extends
FieldArrayType<FormlyFieldConfig>
{ label: "Type", key: "attributeType", parentKey: "attribute", disabled:
true },
];
+ readonly addParameterTypeOptions: AttributeType[] = ["string", "integer",
"long", "double", "boolean", "timestamp"];
+ draftVisible = false;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private uiUdfParametersSyncService: UiUdfParametersSyncService,
+ private notificationService: NotificationService
+ ) {
+ super();
+ }
+
+ get workflowModificationEnabled(): boolean {
+ return this.workflowActionService.checkWorkflowModificationEnabled();
+ }
+
+ /** Inserts the declaration into the operator's Python code; the row then
appears through the normal code sync. */
+ addParameter(nameInput: HTMLInputElement, attributeType: string): void {
+ const operatorId =
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0];
+ try {
+ this.uiUdfParametersSyncService.addParameter(operatorId,
nameInput.value, attributeType as AttributeType);
+ this.draftVisible = false;
+ } catch (error) {
+ if (!(error instanceof UiUdfParametersEditError) && !(error instanceof
UiUdfParametersParseError)) throw error;
+ this.notificationService.error(`Could not add UDF parameter:
${error.message}`);
+ }
+ }
+
override onPopulate(field: FormlyFieldConfig): void {
this.configureRowTemplate(this.getFieldArrayTemplate(field));
super.onPopulate(field);
diff --git
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
index e9fc4e3494..66a808c108 100644
---
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
+++
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts
@@ -18,13 +18,16 @@
*/
import {
+ UiUdfParametersEditError,
UiUdfParametersParseError,
UiUdfParametersParserService,
type UiUdfParameter,
} from "./ui-udf-parameters-parser.service";
+import type { AttributeType } from "../../types/workflow-compiling.interface";
const MULTIPLE_SUPPORTED_CLASSES_ERROR = "Only one Python UDF class can
declare UiParameter values.";
const DUPLICATE_NAME_ERROR = "UiParameter name 'threshold' is declared more
than once.";
+const PASS_ONLY_OPEN = "class ProcessTupleOperator(UDFOperatorV2):\n def
open(self):\n pass\n";
describe("UiUdfParametersParserService", () => {
let service: UiUdfParametersParserService;
@@ -202,6 +205,190 @@ describe("UiUdfParametersParserService", () => {
});
});
+describe("UiUdfParametersParserService.computeParameterInsertion", () => {
+ let service: UiUdfParametersParserService;
+
+ beforeEach(() => {
+ service = new UiUdfParametersParserService();
+ });
+
+ // Each case is the expected file, with the inserted lines marked ">": the
input is the same
+ // file without them. Every case also re-parses the result to prove the
round trip.
+ (
+ [
+ [
+ "insert before existing UiParameter declarations",
+ "b",
+ "double",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ " def open(self):",
+ '> self.b = self.UiParameter(name="b",
type=AttributeType.DOUBLE).value',
+ ' self.a = self.UiParameter(name="a",
type=AttributeType.INT).value',
+ " self.other = 1",
+ ],
+ ],
+ [
+ "insert after an open() docstring and before executable statements",
+ "b",
+ "integer",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ " def open(self):",
+ ' """Load resources."""',
+ '> self.b = self.UiParameter(name="b",
type=AttributeType.INT).value',
+ " self.other = 1",
+ ],
+ ],
+ [
+ "insert before a header-based UiParameter and its following compound
statement",
+ "limit",
+ "string",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ " def open(self):",
+ '> self.limit = self.UiParameter(name="limit",
type=AttributeType.STRING).value',
+ ' if self.UiParameter(name="debug",
type=AttributeType.BOOL).value:',
+ " self.x = 1",
+ " for i in range(3):",
+ " pass",
+ ],
+ ],
+ [
+ "create a decorated open() before the first method when the code uses
@overrides",
+ "b",
+ "double",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ "> @overrides",
+ "> def open(self) -> None:",
+ '> self.b = self.UiParameter(name="b",
type=AttributeType.DOUBLE).value',
+ ">",
+ " @overrides",
+ " def process_tuple(self, tuple_, port):",
+ " yield tuple_",
+ ],
+ ],
+ [
+ "create an undecorated open() after a class docstring",
+ "b",
+ "boolean",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ ' """Doc."""',
+ ">",
+ "> def open(self) -> None:",
+ '> self.b = self.UiParameter(name="b",
type=AttributeType.BOOL).value',
+ ],
+ ],
+ [
+ // Exact user flow: only the import and the class header line of the
template are
+ // uncommented, so the class body holds nothing but comments and
lezer's error node.
+ "start the class body with open() when the template body is still
commented out",
+ "carlos",
+ "string",
+ [
+ "from pytexera import *",
+ "# ",
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ "> def open(self) -> None:",
+ '> self.carlos = self.UiParameter(name="carlos",
type=AttributeType.STRING).value',
+ "# ",
+ "# @overrides",
+ "# def process_tuple(self, tuple_: Tuple, port: int) ->
Iterator[Optional[TupleLike]]:",
+ "# yield tuple_",
+ "# ",
+ "# class ProcessBatchOperator(UDFBatchOperator):",
+ ],
+ ],
+ [
+ "start the open() body when its statements are still commented out",
+ "b",
+ "long",
+ [
+ "class ProcessTupleOperator(UDFOperatorV2):",
+ " def open(self):",
+ '> self.b = self.UiParameter(name="b",
type=AttributeType.LONG).value',
+ " # self.a = 1",
+ "",
+ " def process_tuple(self, tuple_, port):",
+ " yield tuple_",
+ ],
+ ],
+ ] as ReadonlyArray<readonly [string, string, AttributeType, string[]]>
+ ).forEach(([description, name, attributeType, annotatedLines]) => {
+ it(`should ${description}`, () => {
+ const input = pythonLines(...annotatedLines.filter(line =>
!line.startsWith(">")));
+ const expected = pythonLines(...annotatedLines.map(line =>
(line.startsWith(">") ? line.slice(1) : line)));
+
+ const updatedCode = insertParameter(service, input, name, attributeType);
+
+ expect(updatedCode).toBe(expected);
+ expect(service.parse(updatedCode).map(parsed =>
parsed.attribute.attributeName)).toContain(name);
+ });
+ });
+
+ it("should sanitize assignment targets while keeping exact names, and
accumulate declarations", () => {
+ let code = PASS_ONLY_OPEN;
+ code = insertParameter(service, code, "my param-1", "timestamp");
+ code = insertParameter(service, code, "class");
+
+ expect(code).toContain('self.my_param_1 = self.UiParameter(name="my
param-1", type=AttributeType.TIMESTAMP).value');
+ expect(code).toContain('self.class_ = self.UiParameter(name="class",
type=AttributeType.DOUBLE).value');
+ expect(service.parse(code)).toEqual([
+ { attribute: { attributeName: "class", attributeType: "double" }, value:
"" },
+ { attribute: { attributeName: "my param-1", attributeType: "timestamp"
}, value: "" },
+ ]);
+ });
+
+ (
+ [
+ [
+ "no supported UDF class",
+ "class RandomClass(ABC):\n def open(self):\n pass",
+ "b",
+ "double",
+ "No supported Python UDF class",
+ ],
+ [
+ "a duplicate parameter name",
+ PASS_ONLY_OPEN.replace("pass", 'self.b = self.UiParameter(name="b",
type=AttributeType.DOUBLE).value'),
+ "b",
+ "double",
+ "UiParameter name 'b' is declared already.",
+ ],
+ [
+ "a single-line open() body",
+ "class ProcessTupleOperator(UDFOperatorV2):\n def open(self): pass",
+ "b",
+ "double",
+ "need an indented block body",
+ ],
+ ["an empty parameter name", PASS_ONLY_OPEN, " ", "double",
"UiParameter name is required."],
+ ["an unsupported parameter type", PASS_ONLY_OPEN, "b", "binary",
"UiParameter type 'binary' is not supported."],
+ ] as ReadonlyArray<readonly [string, string, string, AttributeType,
string]>
+ ).forEach(([description, code, name, attributeType, message]) => {
+ it(`should raise an error for ${description}`, () => {
+ expect(() => service.computeParameterInsertion(code, name,
attributeType)).toThrow(UiUdfParametersEditError);
+ expect(() => service.computeParameterInsertion(code, name,
attributeType)).toThrow(message);
+ });
+ });
+});
+
+function insertParameter(
+ service: UiUdfParametersParserService,
+ code: string,
+ name: string,
+ attributeType: AttributeType = "double"
+): string {
+ const edit = service.computeParameterInsertion(code, name, attributeType);
+ return code.slice(0, edit.offset) + edit.text + code.slice(edit.offset);
+}
+
+function pythonLines(...lines: string[]): string {
+ return `${lines.join("\n")}\n`;
+}
+
function expectParsed(
service: UiUdfParametersParserService,
openBody: string,
diff --git
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.ts
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.ts
index 5c0acb81d0..3db18b05a9 100644
---
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.ts
+++
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.ts
@@ -32,14 +32,19 @@ const SUPPORTED_CLASS_NAMES = new Set([
const PYTHON_NODE = {
ARG_LIST: "ArgList",
ASSIGN_OP: "AssignOp",
+ BODY: "Body",
CALL_EXPRESSION: "CallExpression",
CLASS_DEFINITION: "ClassDefinition",
+ EXPRESSION_STATEMENT: "ExpressionStatement",
+ FUNCTION_DEFINITION: "FunctionDefinition",
MEMBER_EXPRESSION: "MemberExpression",
PROPERTY_NAME: "PropertyName",
STRING: "String",
VARIABLE_NAME: "VariableName",
} as const;
const ARGUMENT_DELIMITER_NODES = new Set(["(", ")", ","]);
+// "⚠" is lezer's error node: a body whose real statements are still commented
out ends in one.
+const NON_STATEMENT_BODY_NODES = new Set([":", "Comment", "⚠"]);
const UI_PARAMETER_CALLEE = ["self", "UiParameter"];
const ATTRIBUTE_TYPE_RECEIVER = "AttributeType";
@@ -60,6 +65,9 @@ export type UiUdfParameter = Readonly<{ attribute:
SchemaAttribute; value: strin
/** Raised when supported Python UDF code declares UI parameters that cannot
be represented safely in the UI. */
export class UiUdfParametersParseError extends Error {}
+/** Raised when a new UiParameter declaration cannot be inserted into the
Python UDF code. */
+export class UiUdfParametersEditError extends Error {}
+
// Accept Java enum names (INTEGER, BOOLEAN) and Python enum aliases (INT,
BOOL).
const ATTRIBUTE_TYPES_BY_TOKEN: Readonly<Record<string, AttributeType>> = {
STRING: "string",
@@ -72,6 +80,24 @@ const ATTRIBUTE_TYPES_BY_TOKEN: Readonly<Record<string,
AttributeType>> = {
TIMESTAMP: "timestamp",
};
+// Python AttributeType member emitted when generating declarations; matches
the pytexera enum and template style.
+const PYTHON_TOKENS_BY_ATTRIBUTE_TYPE: Readonly<Partial<Record<AttributeType,
string>>> = {
+ string: "STRING",
+ integer: "INT",
+ long: "LONG",
+ double: "DOUBLE",
+ boolean: "BOOL",
+ timestamp: "TIMESTAMP",
+};
+
+// Hard keywords only; soft keywords (match, case, type) are valid attribute
names.
+const PYTHON_KEYWORDS = new Set(
+ (
+ "False None True and as assert async await break class continue def del
elif else except finally " +
+ "for from global if import in is lambda nonlocal not or pass raise return
try while with yield"
+ ).split(" ")
+);
+
/** Parses Python UDF source code and infers supported self.UiParameter(...)
declarations for the property panel. */
@Injectable({ providedIn: "root" })
export class UiUdfParametersParserService {
@@ -83,48 +109,153 @@ export class UiUdfParametersParserService {
if (!code) return [];
const result: UiUdfParameter[] = [];
- const seen = new Set<string>();
- let supportedClassCount = 0;
- let duplicateName: string | undefined;
- const addParameter = (parameter?: UiUdfParameter): void => {
- const name = parameter?.attribute.attributeName;
- if (parameter && name) {
- if (seen.has(name)) {
- duplicateName = name;
- return;
- }
- seen.add(name);
+ const supportedClass = findSupportedClass(code);
+ if (supportedClass)
+ forEachUiParameterCall(supportedClass, code, parameter => {
+ if (result.some(existing => existing.attribute.attributeName ===
parameter.attribute.attributeName))
+ throw new UiUdfParametersParseError(
+ `UiParameter name '${parameter.attribute.attributeName}' is
declared more than once.`
+ );
result.push(parameter);
- }
- };
-
- parser.parse(code).iterate({
- enter: ({ name, node }) => {
- const className = node.getChild(PYTHON_NODE.VARIABLE_NAME);
- if (
- name !== PYTHON_NODE.CLASS_DEFINITION ||
- !className ||
- !SUPPORTED_CLASS_NAMES.has(code.slice(className.from, className.to))
- )
- return;
- supportedClassCount++;
- node.cursor().iterate(cursorReference => {
- if (cursorReference.name !== PYTHON_NODE.CALL_EXPRESSION) return;
- addParameter(readCall(cursorReference.node, code));
- return false;
- });
- return false;
- },
- });
-
- if (supportedClassCount > 1)
- throw new UiUdfParametersParseError("Only one Python UDF class can
declare UiParameter values.");
-
- if (duplicateName)
- throw new UiUdfParametersParseError(`UiParameter name '${duplicateName}'
is declared more than once.`);
-
+ });
return result;
}
+
+ /**
+ * Computes the text insertion that declares a new self.UiParameter(...)
inside open() of the
+ * supported Python UDF class, creating open() when the class does not
define one.
+ * Throws UiUdfParametersEditError when the declaration cannot be placed.
+ */
+ computeParameterInsertion(
+ code: string,
+ name: string,
+ attributeType: AttributeType
+ ): Readonly<{ offset: number; text: string }> {
+ const attributeName = name.trim();
+ const pythonToken = PYTHON_TOKENS_BY_ATTRIBUTE_TYPE[attributeType];
+ if (!attributeName) throw new UiUdfParametersEditError("UiParameter name
is required.");
+ if (!pythonToken) throw new UiUdfParametersEditError(`UiParameter type
'${attributeType}' is not supported.`);
+ if (this.parse(code).some(parameter => parameter.attribute.attributeName
=== attributeName))
+ throw new UiUdfParametersEditError(`UiParameter name '${attributeName}'
is declared already.`);
+
+ const declaration =
+ `self.${toPythonIdentifier(attributeName)} = ` +
+ `self.UiParameter(name=${JSON.stringify(attributeName)},
type=AttributeType.${pythonToken}).value`;
+ const supportedClass = findSupportedClass(code);
+ if (!supportedClass)
+ throw new UiUdfParametersEditError(
+ "No supported Python UDF class (such as ProcessTupleOperator) was
found in the code."
+ );
+
+ const openMethod = findOpenMethod(supportedClass, code);
+ if (openMethod) return insertIntoBody(code, openMethod, [declaration]);
+ return insertIntoBody(code, supportedClass, [
+ ...(/^\s*@overrides\b/m.test(code) ? ["@overrides"] : []),
+ "def open(self) -> None:",
+ ` ${declaration}`,
+ ]);
+ }
+}
+
+/** Returns the single supported UDF class; throws when several declare
UiParameter-capable classes. */
+function findSupportedClass(code: string): ParserSyntaxNode | undefined {
+ const classes: ParserSyntaxNode[] = [];
+ parser.parse(code).iterate({
+ enter: ({ name, node }) => {
+ const className = name === PYTHON_NODE.CLASS_DEFINITION ?
node.getChild(PYTHON_NODE.VARIABLE_NAME) : null;
+ if (!className || !SUPPORTED_CLASS_NAMES.has(code.slice(className.from,
className.to))) return;
+ classes.push(node);
+ return false;
+ },
+ });
+ if (classes.length > 1)
+ throw new UiUdfParametersParseError("Only one Python UDF class can declare
UiParameter values.");
+ return classes[0];
+}
+
+function forEachUiParameterCall(
+ supportedClass: ParserSyntaxNode,
+ code: string,
+ visit: (parameter: UiUdfParameter, call: ParserSyntaxNode) => void
+): void {
+ supportedClass.cursor().iterate(cursorReference => {
+ if (cursorReference.name !== PYTHON_NODE.CALL_EXPRESSION) return;
+ const parameter = readCall(cursorReference.node, code);
+ if (parameter) visit(parameter, cursorReference.node);
+ return false;
+ });
+}
+
+function findOpenMethod(supportedClass: ParserSyntaxNode, code: string):
ParserSyntaxNode | undefined {
+ const body = supportedClass.getChild(PYTHON_NODE.BODY);
+ for (const statement of body ? getChildren(body) : []) {
+ const definition =
+ statement.name === PYTHON_NODE.FUNCTION_DEFINITION
+ ? statement
+ : statement.getChild(PYTHON_NODE.FUNCTION_DEFINITION);
+ const definitionName = definition?.getChild(PYTHON_NODE.VARIABLE_NAME);
+ if (definition && definitionName && code.slice(definitionName.from,
definitionName.to) === "open")
+ return definition;
+ }
+ return undefined;
+}
+
+/**
+ * Inserts lines at the start of a class or def body while preserving a
leading docstring.
+ * When the body has no real statement yet (for example a template whose
statements are all
+ * commented out), the lines go right after the header.
+ */
+function insertIntoBody(
+ code: string,
+ definition: ParserSyntaxNode,
+ lines: string[]
+): Readonly<{ offset: number; text: string }> {
+ const body = definition.getChild(PYTHON_NODE.BODY);
+ const statements = body ? getChildren(body).filter(child =>
!NON_STATEMENT_BODY_NODES.has(child.name)) : [];
+ const first = statements[0];
+ if (body && first && code.slice(body.from, first.from).includes("\n")) {
+ const indent = lineIndentation(code, first.from);
+ if (isDocstringStatement(first)) {
+ const block = lines.map(line => `${indent}${line}`).join("\n");
+ const leadingSeparator = lines.length > 1 ? "\n\n" : "\n";
+ const trailingSeparator = lines.length > 1 && statements.length > 1 ?
"\n" : "";
+ return {
+ offset: lineEnd(code, Math.max(first.from, first.to - 1)),
+ text: `${leadingSeparator}${block}${trailingSeparator}`,
+ };
+ }
+ const block = lines.map(line => `${indent}${line}\n`).join("");
+ // A synthesized open() gets a blank separator line before the statement
that follows it.
+ return { offset: lineStart(code, first.from), text: lines.length > 1 ?
`${block}\n` : block };
+ }
+ if (first || !body)
+ throw new UiUdfParametersEditError(
+ "The Python UDF class and open() need an indented block body to declare
UiParameter values."
+ );
+ const indent = `${lineIndentation(code, definition.from)} `;
+ return { offset: lineEnd(code, body.from), text: lines.map(line =>
`\n${indent}${line}`).join("") };
+}
+
+function isDocstringStatement(statement: ParserSyntaxNode): boolean {
+ return statement.name === PYTHON_NODE.EXPRESSION_STATEMENT &&
statement.getChild(PYTHON_NODE.STRING) !== null;
+}
+
+function toPythonIdentifier(name: string): string {
+ const identifier = name.replace(/\W/g, "_").replace(/^(?=\d)/, "_") ||
"parameter";
+ return PYTHON_KEYWORDS.has(identifier) ? `${identifier}_` : identifier;
+}
+
+function lineStart(code: string, position: number): number {
+ return code.lastIndexOf("\n", position - 1) + 1;
+}
+
+function lineEnd(code: string, position: number): number {
+ const newline = code.indexOf("\n", position);
+ return newline === -1 ? code.length : newline;
+}
+
+function lineIndentation(code: string, position: number): string {
+ return code.slice(lineStart(code, position), position).match(/^[ \t]*/)?.[0]
?? "";
}
function readCall(call: ParserSyntaxNode, code: string): UiUdfParameter |
undefined {
diff --git
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.spec.ts
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.spec.ts
index 3d3613fa5a..e07f565256 100644
---
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.spec.ts
+++
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.spec.ts
@@ -19,7 +19,11 @@
import { WorkflowActionService } from
"../workflow-graph/model/workflow-action.service";
import { PYTHON_UDF_V2_OP_TYPE } from "../workflow-graph/model/workflow-graph";
-import { UiUdfParametersParseError, UiUdfParametersParserService } from
"./ui-udf-parameters-parser.service";
+import {
+ UiUdfParametersEditError,
+ UiUdfParametersParseError,
+ UiUdfParametersParserService,
+} from "./ui-udf-parameters-parser.service";
import type { UiUdfParameter } from "./ui-udf-parameters-parser.service";
import { UiUdfParametersSyncService } from "./ui-udf-parameters-sync.service";
import type { Mock } from "vitest";
@@ -31,7 +35,7 @@ describe("UiUdfParametersSyncService", () => {
const code = "self.UiParameter(...)";
let service: UiUdfParametersSyncService;
- let parserServiceMock: { parse: Mock };
+ let parserServiceMock: { parse: Mock; computeParameterInsertion: Mock };
let graphMock: { getOperator: Mock; getSharedOperatorType: Mock };
let operator: { operatorType: string; operatorProperties: { uiParameters:
UiUdfParameter[] } };
@@ -45,7 +49,7 @@ describe("UiUdfParametersSyncService", () => {
),
getSharedOperatorType: vitest.fn(),
};
- parserServiceMock = { parse: vitest.fn() };
+ parserServiceMock = { parse: vitest.fn(), computeParameterInsertion:
vitest.fn() };
service = new UiUdfParametersSyncService(
{ getTexeraGraph: vitest.fn().mockReturnValue(graphMock) } as unknown as
WorkflowActionService,
parserServiceMock as unknown as UiUdfParametersParserService
@@ -207,6 +211,41 @@ describe("UiUdfParametersSyncService", () => {
}
});
+ it("should insert the computed parameter edit into shared code and re-sync
the structure", () => {
+ const sharedCode = "class ProcessTupleOperator(UDFOperatorV2):\n
pass\n";
+ const sharedOperator = sharedOperatorType(sharedCode);
+ graphMock.getSharedOperatorType.mockReturnValue(sharedOperator);
+ parserServiceMock.computeParameterInsertion.mockReturnValue({ offset: 0,
text: "# inserted\n" });
+ parserServiceMock.parse.mockReturnValue([parameter("count", "integer")]);
+
+ const parametersChangedObserver = observeParameterChanges();
+
+ service.addParameter(operatorId, "count", "integer");
+
+
expect(parserServiceMock.computeParameterInsertion).toHaveBeenCalledWith(sharedCode,
"count", "integer");
+ const yCode = (sharedOperator.get("operatorProperties") as
Yjs.Map<unknown>).get("code") as Yjs.Text;
+ expect(yCode.toString()).toBe(`# inserted\n${sharedCode}`);
+ expect(parserServiceMock.parse).toHaveBeenCalledWith(`#
inserted\n${sharedCode}`);
+ expect(parametersChangedObserver).toHaveBeenCalledWith({
+ operatorId,
+ parameters: [parameter("count", "integer")],
+ });
+ });
+
+ it("should throw without editing when shared code is unavailable", () => {
+ const consoleWarnSpy = vitest.spyOn(console, "warn").mockImplementation(()
=> undefined);
+ graphMock.getSharedOperatorType.mockImplementation(() => {
+ throw new Error("missing shared operator");
+ });
+
+ try {
+ expect(() => service.addParameter(operatorId, "count",
"integer")).toThrow(UiUdfParametersEditError);
+
expect(parserServiceMock.computeParameterInsertion).not.toHaveBeenCalled();
+ } finally {
+ consoleWarnSpy.mockRestore();
+ }
+ });
+
function observeParameterChanges(): Mock {
const parametersChangedObserver = vitest.fn();
service.uiParametersChanged$.subscribe(parametersChangedObserver);
diff --git
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.ts
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.ts
index bb3c832420..54aa5ec363 100644
---
a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.ts
+++
b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-sync.service.ts
@@ -21,12 +21,17 @@ import { isEqual } from "lodash-es";
import { Subject } from "rxjs";
import { debounceTime } from "rxjs/operators";
import { WorkflowActionService } from
"../workflow-graph/model/workflow-action.service";
-import { UiUdfParametersParseError, UiUdfParametersParserService } from
"./ui-udf-parameters-parser.service";
+import {
+ UiUdfParametersEditError,
+ UiUdfParametersParseError,
+ UiUdfParametersParserService,
+} from "./ui-udf-parameters-parser.service";
import type { UiUdfParameter } from "./ui-udf-parameters-parser.service";
import { isDefined } from "../../../common/util/predicate";
import { isPythonUdf } from "../workflow-graph/model/workflow-graph";
import type { Text as YText } from "yjs";
import type { YType } from "../../types/shared-editing.interface";
+import type { AttributeType } from "../../types/workflow-compiling.interface";
type SharedOperatorProperties = Readonly<{ code?: string; [key: string]:
unknown }>;
@@ -117,13 +122,34 @@ export class UiUdfParametersSyncService {
}));
}
+ /**
+ * Inserts a new self.UiParameter declaration into the operator's shared
Python code
+ * (creating open() when missing) and immediately re-syncs the parameter
rows.
+ * Throws UiUdfParametersEditError or UiUdfParametersParseError when the
code cannot be edited.
+ */
+ addParameter(operatorId: string, attributeName: string, attributeType:
AttributeType): void {
+ const yCode = this.getSharedYCode(operatorId);
+ if (!yCode) throw new UiUdfParametersEditError("Python UDF code is not
available for this operator.");
+
+ const edit = this.uiUdfParametersParserService.computeParameterInsertion(
+ yCode.toString(),
+ attributeName,
+ attributeType
+ );
+ yCode.insert(edit.offset, edit.text);
+ this.syncStructureFromCode(operatorId);
+ }
+
private getSharedCode(operatorId: string): string | undefined {
+ return this.getSharedYCode(operatorId)?.toString();
+ }
+
+ private getSharedYCode(operatorId: string): YText | undefined {
try {
const sharedOperatorType =
this.workflowActionService.getTexeraGraph().getSharedOperatorType(operatorId);
const operatorProperties = sharedOperatorType.get("operatorProperties")
as YType<SharedOperatorProperties>;
- const yCode = operatorProperties.get("code") as unknown as YText;
- return yCode?.toString();
+ return operatorProperties.get("code") as unknown as YText;
} catch (error) {
console.warn("Unable to read Python UDF code from shared operator
properties.", error);
return undefined;