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 a7f4386ba7 feat(udf): enable Python UDF UI parameters end to end
(#5912)
a7f4386ba7 is described below
commit a7f4386ba7ccb58155e44f79c8e10a25e60d2208
Author: carloea2 <[email protected]>
AuthorDate: Mon Aug 10 11:35:13 2026 -0700
feat(udf): enable Python UDF UI parameters end to end (#5912)
### What changes were proposed in this PR?
This PR wires the existing Python UDF UI parameter support into the
end-to-end execution path.
It changes:
| Area | Change |
| --- | --- |
| Python UDF descriptors | Adds the `uiParameters` property to Python
UDF, dual-input Python UDF, and Python UDF source operators. |
| Execution wiring | Applies `PythonUdfUiParameterInjector.inject(...)`
before creating Python physical operators. |
| Frontend wiring | Connects the existing UI-parameter sync service to
the code editor and property editor, and renders `uiParameters` with the
existing UI-parameter editor. |
| Operator templates | Adds commented `UiParameter` examples to Python
UDF templates. |
### Any related issues, documentation, discussions?
Part of the Python UDF UI parameter feature split from
`feat/ui-parameter`.
Related tracking issue / stack: #5044
Stack order:
1. Frontend UI parameter building blocks: #5043
2. Scala backend injection model: #5141
3. Python runtime support: #5603
4. End-to-end execution wiring: this PR
### How was this PR tested?
Manually +
Commands run:
```bash
sbt --no-server scalafmtAll scalafixAll
sbt --no-server scalafmtCheckAll "scalafixAll --check"
cd amber
ruff check src/main/python src/test/python
ruff format --check src/main/python src/test/python
cd ..
sbt --no-server "WorkflowExecutionService / Test / testOnly
org.apache.texera.amber.engine.architecture.pythonworker.PythonWorkflowWorkerStartupConfigSpec"
```
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Me
---
amber/src/main/python/pytexera/udf/udf_operator.py | 9 +++++
.../test/python/pytexera/udf/test_udf_operator.py | 27 ++++++++-------
.../python/DualInputPortsPythonUDFOpDescV2.scala | 11 ++++--
.../operator/udf/python/PythonUDFOpDescV2.scala | 11 ++++--
.../udf/python/PythonUdfUiParameterInjector.scala | 28 +++++++++++++--
.../udf/python/PythonUdfUiParameterSupport.scala | 37 ++++++++++++++++++++
.../python/source/PythonUDFSourceOpDescV2.scala | 17 +++++++--
.../DualInputPortsPythonUDFOpDescV2Spec.scala | 28 +++++++++++++++
.../udf/python/PythonUDFOpDescV2Spec.scala | 28 +++++++++++++++
.../python/PythonUdfUiParameterInjectorSpec.scala | 22 ++++++++++++
.../source/PythonUDFSourceOpDescV2Spec.scala | 29 ++++++++++++++++
.../python/1-out-python-udf.md | 5 +++
.../python/2-in-python-udf.md | 5 +++
.../user-defined-functions/python/_index.md | 20 +++++++++++
.../user-defined-functions/python/python-udf.md | 5 +++
frontend/LICENSE-binary | 4 +++
.../code-editor.component.spec.ts | 38 ++++++++++++++++++++
.../code-editor-dialog/code-editor.component.ts | 26 +++++++++++++-
.../operator-property-edit-frame.component.scss | 11 ++++++
.../operator-property-edit-frame.component.spec.ts | 40 ++++++++++++++++++++++
.../operator-property-edit-frame.component.ts | 27 ++++++++++++++-
.../ui-udf-parameters.component.html | 38 ++++++++++----------
.../ui-udf-parameters-sync.service.spec.ts | 21 ++++++++++++
.../code-editor/ui-udf-parameters-sync.service.ts | 8 ++---
24 files changed, 446 insertions(+), 49 deletions(-)
diff --git a/amber/src/main/python/pytexera/udf/udf_operator.py
b/amber/src/main/python/pytexera/udf/udf_operator.py
index 7c0fd239c7..4727c9399a 100644
--- a/amber/src/main/python/pytexera/udf/udf_operator.py
+++ b/amber/src/main/python/pytexera/udf/udf_operator.py
@@ -170,6 +170,15 @@ class _UiParameterSupport:
if value is None:
return None
+ if (
+ attr_type is not AttributeType.STRING
+ and isinstance(value, str)
+ and not value.strip()
+ ):
+ raise ValueError(
+ f"UiParameter value cannot be empty for type {attr_type.name}."
+ )
+
try:
return parser(value)
except Exception as e:
diff --git a/amber/src/test/python/pytexera/udf/test_udf_operator.py
b/amber/src/test/python/pytexera/udf/test_udf_operator.py
index a04e52b3a1..1d93eba5e4 100644
--- a/amber/src/test/python/pytexera/udf/test_udf_operator.py
+++ b/amber/src/test/python/pytexera/udf/test_udf_operator.py
@@ -238,20 +238,23 @@ class TestUiParameterSupport:
assert _UiParameterSupport._parse(raw_value, attr_type) == expected
@pytest.mark.parametrize(
- ("raw_value", "attr_type", "expected"),
+ ("raw_value", "attr_type"),
[
- ("", AttributeType.INT, 0),
- (" ", AttributeType.LONG, 0),
- ("", AttributeType.DOUBLE, 0.0),
- (
- "",
- AttributeType.TIMESTAMP,
- datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc),
- ),
+ ("", AttributeType.INT),
+ (" ", AttributeType.LONG),
+ ("", AttributeType.DOUBLE),
+ ("\t", AttributeType.BOOL),
+ ("", AttributeType.TIMESTAMP),
],
)
- def test_parse_empty_values(self, raw_value, attr_type, expected):
- assert _UiParameterSupport._parse(raw_value, attr_type) == expected
+ def test_parse_empty_non_string_values_raises_value_error(
+ self, raw_value, attr_type
+ ):
+ with pytest.raises(ValueError, match="UiParameter value cannot be
empty"):
+ _UiParameterSupport._parse(raw_value, attr_type)
+
+ def test_parse_empty_string_value(self):
+ assert _UiParameterSupport._parse("", AttributeType.STRING) == ""
def test_java_attribute_type_aliases_parse_like_python_names(self):
assert AttributeType.INTEGER is AttributeType.INT
@@ -262,8 +265,6 @@ class TestUiParameterSupport:
@pytest.mark.parametrize(
("raw_value", "expected"),
[
- ("", False),
- (" ", False),
("True", True),
("true", True),
("1", True),
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2.scala
index 1cfa29c9f1..07fec7af92 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2.scala
@@ -29,11 +29,15 @@ import org.apache.texera.amber.core.workflow._
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants,
OperatorInfo}
-class DualInputPortsPythonUDFOpDescV2 extends LogicalOp {
+class DualInputPortsPythonUDFOpDescV2 extends LogicalOp with
PythonUdfUiParameterSupport {
@JsonProperty(
required = true,
defaultValue =
"# Choose from the following templates:\n" +
+ "# \n" +
+ "# Define UiParameter inside open() of ProcessTupleOperator,
ProcessBatchOperator, or ProcessTableOperator.\n" +
+ "# Example: self.count = self.UiParameter(\"count\",
AttributeType.INT).value\n" +
+ "# See the Python UDF operator documentation for supported types and
behavior.\n" +
"# \n" +
"# from pytexera import *\n" +
"# \n" +
@@ -105,6 +109,7 @@ class DualInputPortsPythonUDFOpDescV2 extends LogicalOp {
)
trimmed
}
+ val codeWithParameters = injectUiParameters(code)
val physicalOp = if (workers > 1) {
PhysicalOp
@@ -112,7 +117,7 @@ class DualInputPortsPythonUDFOpDescV2 extends LogicalOp {
workflowId,
executionId,
operatorIdentifier,
- OpExecWithCode(code, "python")
+ OpExecWithCode(codeWithParameters, "python")
)
.withParallelizable(true)
.withSuggestedWorkerNum(workers)
@@ -122,7 +127,7 @@ class DualInputPortsPythonUDFOpDescV2 extends LogicalOp {
workflowId,
executionId,
operatorIdentifier,
- OpExecWithCode(code, "python")
+ OpExecWithCode(codeWithParameters, "python")
)
.withParallelizable(false)
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2.scala
index 6739041a53..01610979ec 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2.scala
@@ -31,11 +31,15 @@ import org.apache.texera.amber.operator.{LogicalOp,
PortDescription, StateTransf
import scala.util.{Success, Try}
-class PythonUDFOpDescV2 extends LogicalOp {
+class PythonUDFOpDescV2 extends LogicalOp with PythonUdfUiParameterSupport {
@JsonProperty(
required = true,
defaultValue =
"# Choose from the following templates:\n" +
+ "# \n" +
+ "# Define UiParameter inside open() of ProcessTupleOperator,
ProcessBatchOperator, or ProcessTableOperator.\n" +
+ "# Example: self.count = self.UiParameter(\"count\",
AttributeType.INT).value\n" +
+ "# See the Python UDF operator documentation for supported types and
behavior.\n" +
"# \n" +
"# from pytexera import *\n" +
"# \n" +
@@ -102,6 +106,7 @@ class PythonUDFOpDescV2 extends LogicalOp {
} else {
opInfo.inputPorts.map(_ => None)
}
+ val codeWithParameters = injectUiParameters(code)
val propagateSchema = (inputSchemas: Map[PortIdentity, Schema]) => {
val inputSchema = inputSchemas(operatorInfo.inputPorts.head.id)
@@ -130,7 +135,7 @@ class PythonUDFOpDescV2 extends LogicalOp {
workflowId,
executionId,
operatorIdentifier,
- OpExecWithCode(code, "python")
+ OpExecWithCode(codeWithParameters, "python")
)
.withParallelizable(true)
.withSuggestedWorkerNum(workers)
@@ -140,7 +145,7 @@ class PythonUDFOpDescV2 extends LogicalOp {
workflowId,
executionId,
operatorIdentifier,
- OpExecWithCode(code, "python")
+ OpExecWithCode(codeWithParameters, "python")
)
.withParallelizable(false)
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjector.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjector.scala
index 7b26761290..bd7d4334fb 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjector.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjector.scala
@@ -38,14 +38,27 @@ object PythonUdfUiParameterInjector {
private val InjectedUiParametersHookMethodHeader =
s"def $InjectedUiParametersHookMethodName(self) -> Dict[str, Any]:"
private val UnsupportedUiParameterTypes = Set(AttributeType.BINARY,
AttributeType.LARGE_BINARY)
+ private val UiParameterTypesRequiringValues = Set(
+ AttributeType.INTEGER,
+ AttributeType.LONG,
+ AttributeType.DOUBLE,
+ AttributeType.BOOLEAN,
+ AttributeType.TIMESTAMP
+ )
// Keep supported user-facing UDF class names in sync with the frontend
parser.
private val SupportedPythonUdfClassHeaderRegex: Regex =
"""(?m)^([
\t]*)class\s+(ProcessTupleOperator|ProcessBatchOperator|ProcessTableOperator|GenerateOperator)\s*\([^)]*\)\s*:\s*(?:#.*)?$""".r
private def validate(uiParameters: List[UiUDFParameter]): Unit = {
- val attributes = uiParameters.map(parameterAttribute)
- attributes.foreach(validateSupportedType)
+ val parametersWithAttributes =
+ uiParameters.map(parameter => parameter -> parameterAttribute(parameter))
+ val attributes = parametersWithAttributes.map(_._2)
+ parametersWithAttributes.foreach {
+ case (parameter, attribute) =>
+ validateSupportedType(attribute)
+ validateRequiredValue(parameter, attribute)
+ }
attributes
.groupBy(_.getName)
@@ -71,6 +84,17 @@ object PythonUdfUiParameterInjector {
}
}
+ private def validateRequiredValue(parameter: UiUDFParameter, attribute:
Attribute): Unit = {
+ if (
+ UiParameterTypesRequiringValues.contains(attribute.getType) &&
+ Option(parameter.value).forall(_.trim.isEmpty)
+ ) {
+ throw new RuntimeException(
+ s"UiParameter '${attribute.getName}' requires a value for type
'${attribute.getType.name()}'."
+ )
+ }
+ }
+
private def buildInjectedParameterEntry(parameter: UiUDFParameter):
PythonTemplateBuilder = {
pyb"${parameter.attribute.getName}: ${parameter.value}"
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterSupport.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterSupport.scala
new file mode 100644
index 0000000000..97548c344b
--- /dev/null
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterSupport.scala
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.amber.operator.udf.python
+
+import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
+import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle
+
+/** Shared serialized UI-parameter property and code-injection hook for Python
UDF descriptors. */
+trait PythonUdfUiParameterSupport {
+
+ @JsonProperty
+ @JsonSchemaTitle("Parameters")
+ @JsonPropertyDescription(
+ "Parameters inferred from active self.UiParameter(...) calls in the Python
script"
+ )
+ var uiParameters: List[UiUDFParameter] = List()
+
+ protected final def injectUiParameters(code: String): String =
+ PythonUdfUiParameterInjector.inject(code, uiParameters)
+}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2.scala
index 840c37238a..6ddfa23e01 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2.scala
@@ -27,12 +27,17 @@ import
org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, Workflow
import org.apache.texera.amber.core.workflow.{OutputPort, PhysicalOp,
SchemaPropagationFunc}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants,
OperatorInfo}
import org.apache.texera.amber.operator.source.SourceOperatorDescriptor
+import org.apache.texera.amber.operator.udf.python.PythonUdfUiParameterSupport
-class PythonUDFSourceOpDescV2 extends SourceOperatorDescriptor {
+class PythonUDFSourceOpDescV2 extends SourceOperatorDescriptor with
PythonUdfUiParameterSupport {
@JsonProperty(
required = true,
- defaultValue = "# from pytexera import *\n" +
+ defaultValue = "# Define UiParameter inside GenerateOperator.open().\n" +
+ "# Example: self.count = self.UiParameter(\"count\",
AttributeType.INT).value\n" +
+ "# See the Python UDF operator documentation for supported types and
behavior.\n" +
+ "# \n" +
+ "# from pytexera import *\n" +
"# class GenerateOperator(UDFSourceOperator):\n" +
"# \n" +
"# @overrides\n" +
@@ -82,9 +87,15 @@ class PythonUDFSourceOpDescV2 extends
SourceOperatorDescriptor {
)
trimmed
}
+ val codeWithParameters = injectUiParameters(code)
val physicalOp = PhysicalOp
- .sourcePhysicalOp(workflowId, executionId, operatorIdentifier,
OpExecWithCode(code, "python"))
+ .sourcePhysicalOp(
+ workflowId,
+ executionId,
+ operatorIdentifier,
+ OpExecWithCode(codeWithParameters, "python")
+ )
.withInputPorts(operatorInfo.inputPorts)
.withOutputPorts(operatorInfo.outputPorts)
.withIsOneToManyOp(true)
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2Spec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2Spec.scala
index 6c246d5008..74caa27379 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2Spec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/DualInputPortsPythonUDFOpDescV2Spec.scala
@@ -34,6 +34,13 @@ class DualInputPortsPythonUDFOpDescV2Spec extends
AnyFlatSpec with Matchers {
private val workflowId = WorkflowIdentity(1L)
private val executionId = ExecutionIdentity(1L)
+ private def uiParameter(name: String, value: String): UiUDFParameter = {
+ val parameter = new UiUDFParameter
+ parameter.attribute = new Attribute(name, AttributeType.INTEGER)
+ parameter.value = value
+ parameter
+ }
+
private def twoPortSchemas(dataSchema: Schema): Map[PortIdentity, Schema] =
Map(
PortIdentity() -> Schema().add(new Attribute("model",
AttributeType.STRING)),
@@ -87,6 +94,24 @@ class DualInputPortsPythonUDFOpDescV2Spec extends
AnyFlatSpec with Matchers {
physical.suggestedWorkerNum shouldBe Some(2)
}
+ it should "inject configured UI parameters into the generated Python code"
in {
+ val d = new DualInputPortsPythonUDFOpDescV2
+ d.code = """from pytexera import *
+ |
+ |class ProcessTupleOperator(UDFOperatorV2):
+ | def process_tuple(self, tuple_, port):
+ | yield tuple_
+ |""".stripMargin
+ d.uiParameters = List(uiParameter("count", "7"))
+
+ d.getPhysicalOp(workflowId, executionId).opExecInitInfo match {
+ case OpExecWithCode(code, "python") =>
+ code should include("def _texera_injected_ui_parameters")
+ code should include("self.decode_python_template")
+ case other => fail(s"expected Python OpExecWithCode, got $other")
+ }
+ }
+
it should "reject a blank virtual-environment name when the default env is
disabled" in {
val d = new DualInputPortsPythonUDFOpDescV2
d.defaultEnv = false
@@ -154,6 +179,7 @@ class DualInputPortsPythonUDFOpDescV2Spec extends
AnyFlatSpec with Matchers {
d.defaultEnv = false
d.envName = "myenv"
d.outputColumns = List(new Attribute("res", AttributeType.INTEGER))
+ d.uiParameters = List(uiParameter("count", "7"))
val restored = objectMapper.readValue(objectMapper.writeValueAsString(d),
classOf[LogicalOp])
restored shouldBe a[DualInputPortsPythonUDFOpDescV2]
val r = restored.asInstanceOf[DualInputPortsPythonUDFOpDescV2]
@@ -163,5 +189,7 @@ class DualInputPortsPythonUDFOpDescV2Spec extends
AnyFlatSpec with Matchers {
r.defaultEnv shouldBe false
r.envName shouldBe "myenv"
r.outputColumns shouldBe List(new Attribute("res", AttributeType.INTEGER))
+ r.uiParameters.map(_.attribute) shouldBe List(new Attribute("count",
AttributeType.INTEGER))
+ r.uiParameters.map(_.value) shouldBe List("7")
}
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2Spec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2Spec.scala
index aea38193aa..a36dee6107 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2Spec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUDFOpDescV2Spec.scala
@@ -34,6 +34,13 @@ class PythonUDFOpDescV2Spec extends AnyFlatSpec with
Matchers {
private val workflowId = WorkflowIdentity(1L)
private val executionId = ExecutionIdentity(1L)
+ private def uiParameter(name: String, value: String): UiUDFParameter = {
+ val parameter = new UiUDFParameter
+ parameter.attribute = new Attribute(name, AttributeType.INTEGER)
+ parameter.value = value
+ parameter
+ }
+
"PythonUDFOpDescV2.operatorInfo" should
"advertise the name, Python group, dynamic ports, and a default 1-in/1-out
shape" in {
val info = (new PythonUDFOpDescV2).operatorInfo
@@ -74,6 +81,24 @@ class PythonUDFOpDescV2Spec extends AnyFlatSpec with
Matchers {
intercept[IllegalArgumentException] { d.getPhysicalOp(workflowId,
executionId) }
}
+ it should "inject configured UI parameters into the generated Python code"
in {
+ val d = new PythonUDFOpDescV2
+ d.code = """from pytexera import *
+ |
+ |class ProcessTupleOperator(UDFOperatorV2):
+ | def process_tuple(self, tuple_, port):
+ | yield tuple_
+ |""".stripMargin
+ d.uiParameters = List(uiParameter("count", "7"))
+
+ d.getPhysicalOp(workflowId, executionId).opExecInitInfo match {
+ case OpExecWithCode(code, "python") =>
+ code should include("def _texera_injected_ui_parameters")
+ code should include("self.decode_python_template")
+ case other => fail(s"expected Python OpExecWithCode, got $other")
+ }
+ }
+
it should "reject a blank virtual-environment name when the default env is
disabled" in {
val d = new PythonUDFOpDescV2
d.defaultEnv = false
@@ -125,6 +150,7 @@ class PythonUDFOpDescV2Spec extends AnyFlatSpec with
Matchers {
d.defaultEnv = false
d.envName = "myenv"
d.outputColumns = List(new Attribute("res", AttributeType.INTEGER))
+ d.uiParameters = List(uiParameter("count", "7"))
val restored = objectMapper.readValue(objectMapper.writeValueAsString(d),
classOf[LogicalOp])
restored shouldBe a[PythonUDFOpDescV2]
val p = restored.asInstanceOf[PythonUDFOpDescV2]
@@ -134,6 +160,8 @@ class PythonUDFOpDescV2Spec extends AnyFlatSpec with
Matchers {
p.defaultEnv shouldBe false
p.envName shouldBe "myenv"
p.outputColumns shouldBe List(new Attribute("res", AttributeType.INTEGER))
+ p.uiParameters.map(_.attribute) shouldBe List(new Attribute("count",
AttributeType.INTEGER))
+ p.uiParameters.map(_.value) shouldBe List("7")
}
"PythonUDFOpDescV2.getPhysicalOp" should
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjectorSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjectorSpec.scala
index a741dfbf03..bf387a7aa2 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjectorSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/PythonUdfUiParameterInjectorSpec.scala
@@ -208,6 +208,28 @@ class PythonUdfUiParameterInjectorSpec extends AnyFlatSpec
with Matchers {
exception.getMessage should include("UiParameter name 'date' is declared
more than once")
}
+ Seq(
+ AttributeType.INTEGER,
+ AttributeType.LONG,
+ AttributeType.DOUBLE,
+ AttributeType.BOOLEAN,
+ AttributeType.TIMESTAMP
+ ).foreach { attributeType =>
+ it should s"throw when a ${attributeType.name()} UI parameter value is
blank" in {
+ val exception = the[RuntimeException] thrownBy {
+ inject(uiParameter("required", attributeType, " "))
+ }
+
+ exception.getMessage should include("UiParameter 'required' requires a
value")
+ }
+ }
+
+ it should "allow an empty string UI parameter value" in {
+ inject(uiParameter("optional_text", AttributeType.STRING, "")) should
include(
+ "def _texera_injected_ui_parameters"
+ )
+ }
+
Seq(AttributeType.BINARY, AttributeType.LARGE_BINARY).foreach {
unsupportedType =>
it should s"throw when a UI parameter uses ${unsupportedType.name()} type"
in {
val exception = the[RuntimeException] thrownBy {
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2Spec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2Spec.scala
index c6320d775e..cda363511b 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2Spec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/udf/python/source/PythonUDFSourceOpDescV2Spec.scala
@@ -24,6 +24,7 @@ import org.apache.texera.amber.core.tuple.{Attribute,
AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity,
WorkflowIdentity}
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.apache.texera.amber.operator.udf.python.UiUDFParameter
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -33,6 +34,13 @@ class PythonUDFSourceOpDescV2Spec extends AnyFlatSpec with
Matchers {
private val workflowId = WorkflowIdentity(1L)
private val executionId = ExecutionIdentity(1L)
+ private def uiParameter(name: String, value: String): UiUDFParameter = {
+ val parameter = new UiUDFParameter
+ parameter.attribute = new Attribute(name, AttributeType.INTEGER)
+ parameter.value = value
+ parameter
+ }
+
"PythonUDFSourceOpDescV2.operatorInfo" should
"advertise the 1-out Python UDF source (no inputs, one output,
reconfigurable)" in {
val info = (new PythonUDFSourceOpDescV2).operatorInfo
@@ -70,6 +78,24 @@ class PythonUDFSourceOpDescV2Spec extends AnyFlatSpec with
Matchers {
intercept[IllegalArgumentException] { d.getPhysicalOp(workflowId,
executionId) }
}
+ it should "inject configured UI parameters into the generated Python code"
in {
+ val d = new PythonUDFSourceOpDescV2
+ d.code = """from pytexera import *
+ |
+ |class GenerateOperator(UDFSourceOperator):
+ | def produce(self):
+ | yield
+ |""".stripMargin
+ d.uiParameters = List(uiParameter("count", "7"))
+
+ d.getPhysicalOp(workflowId, executionId).opExecInitInfo match {
+ case OpExecWithCode(code, "python") =>
+ code should include("def _texera_injected_ui_parameters")
+ code should include("self.decode_python_template")
+ case other => fail(s"expected Python OpExecWithCode, got $other")
+ }
+ }
+
it should "reject a blank virtual-environment name when the default env is
disabled" in {
val d = new PythonUDFSourceOpDescV2
d.code = "yield"
@@ -104,6 +130,7 @@ class PythonUDFSourceOpDescV2Spec extends AnyFlatSpec with
Matchers {
d.defaultEnv = false
d.envName = "venv"
d.columns = List(new Attribute("a", AttributeType.STRING))
+ d.uiParameters = List(uiParameter("count", "7"))
val restored = objectMapper.readValue(objectMapper.writeValueAsString(d),
classOf[LogicalOp])
restored shouldBe a[PythonUDFSourceOpDescV2]
val p = restored.asInstanceOf[PythonUDFSourceOpDescV2]
@@ -112,5 +139,7 @@ class PythonUDFSourceOpDescV2Spec extends AnyFlatSpec with
Matchers {
p.defaultEnv shouldBe false
p.envName shouldBe "venv"
p.columns shouldBe List(new Attribute("a", AttributeType.STRING))
+ p.uiParameters.map(_.attribute) shouldBe List(new Attribute("count",
AttributeType.INTEGER))
+ p.uiParameters.map(_.value) shouldBe List("7")
}
}
diff --git
a/docs/reference/operators/user-defined-functions/python/1-out-python-udf.md
b/docs/reference/operators/user-defined-functions/python/1-out-python-udf.md
index 8c0729125b..69f68d25d8 100644
--- a/docs/reference/operators/user-defined-functions/python/1-out-python-udf.md
+++ b/docs/reference/operators/user-defined-functions/python/1-out-python-udf.md
@@ -36,12 +36,17 @@ tags: [user-defined-functions, python]
| Columns | | List<Attribute> | - | The columns of the source |
| ↳ Attribute Name | ✓ | String | - | |
| ↳ Attribute Type | ✓ | string, integer, long, double, boolean,<br>timestamp,
binary, large_binary | - | |
+| Parameters | | List<UiUDFParameter> | - | Values inferred from active
`self.UiParameter(...)` calls.<br>See [UI parameters](../#ui-parameters). |
#### Default Code Template
**Python script**
```python
+# Define UiParameter inside GenerateOperator.open().
+# Example: self.count = self.UiParameter("count", AttributeType.INT).value
+# See the Python UDF operator documentation for supported types and behavior.
+#
# from pytexera import *
# class GenerateOperator(UDFSourceOperator):
#
diff --git
a/docs/reference/operators/user-defined-functions/python/2-in-python-udf.md
b/docs/reference/operators/user-defined-functions/python/2-in-python-udf.md
index d305f5ec33..5c0c398a62 100644
--- a/docs/reference/operators/user-defined-functions/python/2-in-python-udf.md
+++ b/docs/reference/operators/user-defined-functions/python/2-in-python-udf.md
@@ -37,6 +37,7 @@ tags: [user-defined-functions, python]
| Extra output column(s) | | List<Attribute> | - | Name of the newly added
output columns that the<br>UDF will produce, if any |
| ↳ Attribute Name | ✓ | String | - | |
| ↳ Attribute Type | ✓ | string, integer, long, double, boolean,<br>timestamp,
binary, large_binary | - | |
+| Parameters | | List<UiUDFParameter> | - | Values inferred from active
`self.UiParameter(...)` calls.<br>See [UI parameters](../#ui-parameters). |
#### Default Code Template
@@ -44,6 +45,10 @@ tags: [user-defined-functions, python]
```python
# Choose from the following templates:
+#
+# Define UiParameter inside open() of ProcessTupleOperator,
ProcessBatchOperator, or ProcessTableOperator.
+# Example: self.count = self.UiParameter("count", AttributeType.INT).value
+# See the Python UDF operator documentation for supported types and behavior.
#
# from pytexera import *
#
diff --git a/docs/reference/operators/user-defined-functions/python/_index.md
b/docs/reference/operators/user-defined-functions/python/_index.md
index 2ad98d4d60..18ed8d59cd 100644
--- a/docs/reference/operators/user-defined-functions/python/_index.md
+++ b/docs/reference/operators/user-defined-functions/python/_index.md
@@ -38,3 +38,23 @@ tags: [user-defined-functions, python]
| [Python UDF](python-udf/) | User-defined function operator in Python script |
**Total**: 5 operators
+
+## UI parameters
+
+The Python UDF, 2-in Python UDF, and 1-out Python UDF operators can expose
values in the property panel. Declare each value with `self.UiParameter(...)`
inside the UDF class's `open()` method, then use its `.value` in later methods.
+
+```python
+from pytexera import *
+
+class ProcessTupleOperator(UDFOperatorV2):
+ @overrides
+ def open(self):
+ self.count = self.UiParameter("count", AttributeType.INT).value
+
+ @overrides
+ def process_tuple(self, tuple_: Tuple, port: int):
+ # self.count contains the value entered in the property panel.
+ yield tuple_
+```
+
+Active calls are inferred from the script; commented-out calls are ignored.
The supported classes are `ProcessTupleOperator`, `ProcessBatchOperator`,
`ProcessTableOperator`, and `GenerateOperator`. Supported types are `STRING`,
`INT`/`LONG`, `DOUBLE`, `BOOL`, and `TIMESTAMP`. Empty strings are valid for
`STRING`; all other types require a non-empty value before execution.
diff --git
a/docs/reference/operators/user-defined-functions/python/python-udf.md
b/docs/reference/operators/user-defined-functions/python/python-udf.md
index 93e951ee61..3f1a1e7f85 100644
--- a/docs/reference/operators/user-defined-functions/python/python-udf.md
+++ b/docs/reference/operators/user-defined-functions/python/python-udf.md
@@ -37,6 +37,7 @@ tags: [user-defined-functions, python]
| Extra output column(s) | | List<Attribute> | - | Name of the newly added
output columns that the<br>UDF will produce, if any |
| ↳ Attribute Name | ✓ | String | - | |
| ↳ Attribute Type | ✓ | string, integer, long, double, boolean,<br>timestamp,
binary, large_binary | - | |
+| Parameters | | List<UiUDFParameter> | - | Values inferred from active
`self.UiParameter(...)` calls.<br>See [UI parameters](../#ui-parameters). |
#### Default Code Template
@@ -44,6 +45,10 @@ tags: [user-defined-functions, python]
```python
# Choose from the following templates:
+#
+# Define UiParameter inside open() of ProcessTupleOperator,
ProcessBatchOperator, or ProcessTableOperator.
+# Example: self.count = self.UiParameter("count", AttributeType.INT).value
+# See the Python UDF operator documentation for supported types and behavior.
#
# from pytexera import *
#
diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary
index 61e929c65c..a2c99bfa9d 100644
--- a/frontend/LICENSE-binary
+++ b/frontend/LICENSE-binary
@@ -291,6 +291,10 @@ Angular / npm packages:
- @codingame/[email protected]
- @codingame/[email protected]
- @ctrl/[email protected]
+ - @lezer/[email protected]
+ - @lezer/[email protected]
+ - @lezer/[email protected]
+ - @lezer/[email protected]
- @ngneat/[email protected]
- @ngx-formly/[email protected]
- @ngx-formly/[email protected]
diff --git
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
index c262036e87..6c9bd5af88 100644
---
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
+++
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
@@ -31,6 +31,8 @@ import { OperatorSchema } from
"../../types/operator-schema.interface";
import { of, Subject } from "rxjs";
import { AIAssistantService } from
"../../service/ai-assistant/ai-assistant.service";
import * as monaco from "monaco-editor";
+import { UiUdfParametersSyncService } from
"../../service/code-editor/ui-udf-parameters-sync.service";
+import { NotificationService } from
"../../../common/service/notification/notification.service";
// Operator types that the constructor's language-detection branch must map
// to a specific language. `RUDFSource` / `RUDF` -> `r`; the three V2 Python
@@ -91,12 +93,24 @@ const buildPredicate = (operatorID: string, operatorType:
string): OperatorPredi
describe("CodeEditorComponent", () => {
let workflowActionService: WorkflowActionService;
+ let uiParametersParseErrors: Subject<{ operatorId: string; message?: string
}>;
+ let notificationServiceMock: { error: ReturnType<typeof vi.fn> };
beforeEach(async () => {
+ uiParametersParseErrors = new Subject();
+ notificationServiceMock = { error: vi.fn() };
await TestBed.configureTestingModule({
providers: [
WorkflowActionService,
{ provide: OperatorMetadataService, useClass:
AugmentedStubMetadataService },
+ {
+ provide: UiUdfParametersSyncService,
+ useValue: {
+ attachToYCode: vi.fn(() => () => undefined),
+ uiParametersParseError$: uiParametersParseErrors.asObservable(),
+ },
+ },
+ { provide: NotificationService, useValue: notificationServiceMock },
...commonTestProviders,
],
imports: [CodeEditorComponent, HttpClientTestingModule],
@@ -119,6 +133,30 @@ describe("CodeEditorComponent", () => {
expect(fixture.componentInstance.currentOperatorId).toBe(mockJavaUDFPredicate.operatorID);
});
+ it("shows UI parameter parser errors for the edited operator", () => {
+ const predicate = buildPredicate("python-with-invalid-parameters",
"PythonUDFV2");
+ makeFixture(predicate);
+
+ uiParametersParseErrors.next({
+ operatorId: predicate.operatorID,
+ message: "UiParameter name 'count' is declared more than once.",
+ });
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith(
+ "Could not update UDF parameters: UiParameter name 'count' is declared
more than once."
+ );
+ });
+
+ it("ignores UI parameter parser events for other operators and successful
parses", () => {
+ const predicate = buildPredicate("python-with-valid-parameters",
"PythonUDFV2");
+ makeFixture(predicate);
+
+ uiParametersParseErrors.next({ operatorId: "another-operator", message:
"invalid" });
+ uiParametersParseErrors.next({ operatorId: predicate.operatorID });
+
+ expect(notificationServiceMock.error).not.toHaveBeenCalled();
+ });
+
// Language detection — the constructor maps `RUDFSource` / `RUDF` to `r`,
// the three V2-era Python operator types to `python`, and anything else
// to `java`. The exact branch lives in the constructor; the public
diff --git
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts
index 5dd1a6791f..d68128312e 100644
---
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts
+++
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts
@@ -60,6 +60,8 @@ import { NzButtonComponent } from "ng-zorro-antd/button";
import { ɵNzTransitionPatchDirective } from
"ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { NgFor, NgComponentOutlet, NgIf } from "@angular/common";
+import { UiUdfParametersSyncService } from
"../../service/code-editor/ui-udf-parameters-sync.service";
+import { NotificationService } from
"../../../common/service/notification/notification.service";
type MonacoEditor = monaco.editor.IStandaloneCodeEditor;
@@ -110,6 +112,7 @@ export class CodeEditorComponent implements AfterViewInit,
SafeStyle, OnDestroy
private editorApp?: EditorApp;
private languageClientWrapper?: LanguageClientWrapper;
private monacoBinding?: MonacoBinding;
+ private detachYCodeListener?: () => void;
// Boolean to determine whether the suggestion UI should be shown
public showAnnotationSuggestion: boolean = false;
@@ -144,7 +147,9 @@ export class CodeEditorComponent implements AfterViewInit,
SafeStyle, OnDestroy
private workflowVersionService: WorkflowVersionService,
public coeditorPresenceService: CoeditorPresenceService,
private aiAssistantService: AIAssistantService,
- private config: GuiConfigService
+ private config: GuiConfigService,
+ private uiUdfParametersSyncService: UiUdfParametersSyncService,
+ private notificationService: NotificationService
) {
this.currentOperatorId =
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0];
const operatorType =
this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId).operatorType;
@@ -171,6 +176,14 @@ export class CodeEditorComponent implements AfterViewInit,
SafeStyle, OnDestroy
const style = localStorage.getItem(this.currentOperatorId);
if (style) this.containerElement.nativeElement.style.cssText = style;
+ this.uiUdfParametersSyncService.uiParametersParseError$
+ .pipe(untilDestroyed(this))
+ .subscribe(({ operatorId, message }) => {
+ if (operatorId === this.currentOperatorId && message) {
+ this.notificationService.error(`Could not update UDF parameters:
${message}`);
+ }
+ });
+
// start editor
this.workflowVersionService
.getDisplayParticularVersionStream()
@@ -196,6 +209,10 @@ export class CodeEditorComponent implements AfterViewInit,
SafeStyle, OnDestroy
this.workflowVersionStreamSubject.next();
this.workflowVersionStreamSubject.complete();
+
+ if (this.detachYCodeListener) {
+ this.detachYCodeListener();
+ }
}
/**
@@ -403,6 +420,13 @@ export class CodeEditorComponent implements AfterViewInit,
SafeStyle, OnDestroy
}
this.setupAIAssistantActions(editor);
this.initCodeDebuggerComponent(editor);
+ if (this.detachYCodeListener) {
+ this.detachYCodeListener();
+ }
+
+ if (this.code) {
+ this.detachYCodeListener =
this.uiUdfParametersSyncService.attachToYCode(this.currentOperatorId,
this.code);
+ }
});
}
diff --git
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss
index 740ac59204..92a8e9080d 100644
---
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss
+++
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss
@@ -212,3 +212,14 @@
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
+
+/* Style only the UDF Parameters field using the widget's stable class. */
+:host ::ng-deep nz-form-item:has(.ui-udf-parameters-field) {
+ border-top: 1px solid #d9d9d9;
+ padding-top: 12px;
+ margin-top: 8px;
+
+ label {
+ font-weight: 600;
+ }
+}
diff --git
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index aba2811444..a127d0bc6d 100644
---
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -60,6 +60,7 @@ import { commonTestProviders } from
"../../../../common/testing/test-utils";
import { DynamicSchemaService } from
"../../../service/dynamic-schema/dynamic-schema.service";
import { NotificationService } from
"../../../../common/service/notification/notification.service";
import { WorkflowGraph } from
"../../../service/workflow-graph/model/workflow-graph";
+import { UiUdfParametersSyncService } from
"../../../service/code-editor/ui-udf-parameters-sync.service";
const { marbles } = configure({ run: false });
@@ -208,6 +209,37 @@ describe("OperatorPropertyEditFrameComponent", () => {
expect(emitEventCounter).toEqual(1);
}));
+ it("keeps code-inferred UI parameters in the form model and subsequent form
edits", fakeAsync(() => {
+ const predicate = {
+ ...mockScanPredicate,
+ operatorProperties: { tableName: "before", uiParameters: [] },
+ };
+ workflowActionService.addOperator(predicate, mockPoint);
+ component.ngOnChanges({
+ currentOperatorId: new SimpleChange(undefined, predicate.operatorID,
true),
+ });
+ fixture.detectChanges();
+ tick(COLLAB_DEBOUNCE_TIME_MS);
+
+ const inferredParameters = [{ attribute: { attributeName: "count",
attributeType: "integer" }, value: "" }];
+ const syncService = TestBed.inject(UiUdfParametersSyncService);
+ (syncService as any).uiParametersChangedSubject.next({
+ operatorId: predicate.operatorID,
+ parameters: inferredParameters,
+ });
+
+ expect(component.formData.uiParameters).toEqual(inferredParameters);
+
+ component.onFormChanges({ ...component.formData, tableName: "after" });
+ tick(FORM_DEBOUNCE_TIME_MS + 10);
+
+
expect(workflowActionService.getTexeraGraph().getOperator(predicate.operatorID).operatorProperties).toEqual({
+ tableName: "after",
+ uiParameters: inferredParameters,
+ });
+ discardPeriodicTasks();
+ }));
+
it.skip(
"should debounce the user form input to avoid emitting event too
frequently",
marbles(m => {
@@ -1698,6 +1730,14 @@ describe("OperatorPropertyEditFrameComponent", () => {
expect(getField("datasetVersionPath")?.type).toBe("datasetversionselector");
});
+ it("maps uiParameters to the ui-udf-parameters field type", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { uiParameters: { type: "array" } },
+ });
+ expect(getField("uiParameters")?.type).toBe("ui-udf-parameters");
+ });
+
it("maps a field described as 'Input your code here' to the codearea field
type", () => {
component.setFormlyFormBinding({
type: "object",
diff --git
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index f4b356c474..beedbabd90 100644
---
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -74,6 +74,7 @@ import { WorkflowPveService } from
"../../../service/virtual-environment/virtual
import { ComputingUnitStatusService } from
"../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { of } from "rxjs";
import { map, switchMap, take } from "rxjs/operators";
+import { UiUdfParametersSyncService } from
"../../../service/code-editor/ui-udf-parameters-sync.service";
Quill.register("modules/cursors", QuillCursors);
@@ -460,7 +461,8 @@ export class OperatorPropertyEditFrameComponent implements
OnInit, OnChanges, On
private workflowStatusSerivce: WorkflowStatusService,
private config: GuiConfigService,
private workflowPveService: WorkflowPveService,
- private computingUnitStatusService: ComputingUnitStatusService
+ private computingUnitStatusService: ComputingUnitStatusService,
+ private uiUdfParametersSyncService: UiUdfParametersSyncService
) {}
private patchPythonUdfEnvironmentSchema(schema: CustomJSONSchema7,
environments: string[]): CustomJSONSchema7 {
@@ -516,6 +518,25 @@ export class OperatorPropertyEditFrameComponent implements
OnInit, OnChanges, On
this.currentOperatorStatus = update[this.currentOperatorId];
}
});
+
+ this.uiUdfParametersSyncService.uiParametersChanged$
+ .pipe(untilDestroyed(this))
+ .subscribe(({ operatorId, parameters }) => {
+ if (operatorId !== this.currentOperatorId) return;
+
+ const currentOperator =
this.workflowActionService.getTexeraGraph().getOperator(operatorId);
+
+ const newModel = {
+ ...cloneDeep(currentOperator.operatorProperties),
+ uiParameters: cloneDeep(parameters),
+ };
+
+ this.listeningToChange = false;
+ this.formData = cloneDeep(newModel);
+ this.workflowActionService.setOperatorProperty(operatorId, newModel);
+ this.listeningToChange = true;
+ this.changeDetectorRef.detectChanges();
+ });
}
private isHuggingFaceOperator(): boolean {
@@ -1038,6 +1059,10 @@ export class OperatorPropertyEditFrameComponent
implements OnInit, OnChanges, On
}
}
+ if (mappedField.key === "uiParameters") {
+ mappedField.type = "ui-udf-parameters";
+ }
+
if (mappedField.key === "datasetVersionPath") {
mappedField.type = "datasetversionselector";
}
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 c56391ab47..26ea63b827 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
@@ -16,28 +16,30 @@
specific language governing permissions and limitations
under the License.
-->
-<div
- class="ui-udf-parameter-list"
- *ngIf="model?.length">
- <div class="ui-udf-parameter-row header">
- <div
- class="field-cell"
- *ngFor="let column of fieldColumns">
- <span class="col-title">{{ column.label }}</span>
- </div>
- </div>
-
+<div class="ui-udf-parameters-field">
<div
- class="ui-udf-parameter-row"
- *ngFor="let parameter of (model || []); let i = index; trackBy:
trackByParameterName">
- <ng-container *ngIf="field.fieldGroup?.[i] as rowField">
+ class="ui-udf-parameter-list"
+ *ngIf="model?.length">
+ <div class="ui-udf-parameter-row header">
<div
class="field-cell"
*ngFor="let column of fieldColumns">
- <formly-field
- *ngIf="getColumnField(rowField, column) as columnField"
- [field]="columnField"></formly-field>
+ <span class="col-title">{{ column.label }}</span>
</div>
- </ng-container>
+ </div>
+
+ <div
+ class="ui-udf-parameter-row"
+ *ngFor="let parameter of (model || []); let i = index; trackBy:
trackByParameterName">
+ <ng-container *ngIf="field.fieldGroup?.[i] as rowField">
+ <div
+ class="field-cell"
+ *ngFor="let column of fieldColumns">
+ <formly-field
+ *ngIf="getColumnField(rowField, column) as columnField"
+ [field]="columnField"></formly-field>
+ </div>
+ </ng-container>
+ </div>
</div>
</div>
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 d755978ca6..3d3613fa5a 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
@@ -90,6 +90,15 @@ describe("UiUdfParametersSyncService", () => {
expect(parametersChangedObserver).not.toHaveBeenCalled();
});
+ it("should not replay a previous parameter change to a late subscriber", ()
=> {
+ parserServiceMock.parse.mockReturnValue([parameter("count", "integer")]);
+
+ service.syncStructureFromCode(operatorId, code);
+
+ const lateObserver = observeParameterChanges();
+ expect(lateObserver).not.toHaveBeenCalled();
+ });
+
it("should emit parser errors without replacing the current parameters", ()
=> {
operator.operatorProperties.uiParameters = [parameter("count", "integer",
"42")];
parserServiceMock.parse.mockImplementation(() => {
@@ -109,6 +118,18 @@ describe("UiUdfParametersSyncService", () => {
});
});
+ it("should not replay a previous parser error to a late subscriber", () => {
+ parserServiceMock.parse.mockImplementation(() => {
+ throw new UiUdfParametersParseError("invalid parameters");
+ });
+
+ service.syncStructureFromCode(operatorId, code);
+
+ const lateObserver = vitest.fn();
+ service.uiParametersParseError$.subscribe(lateObserver);
+ expect(lateObserver).not.toHaveBeenCalled();
+ });
+
it("should not parse code for non-Python UDF operators", () => {
operator.operatorType = "Projection";
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 519d54aaa9..bb3c832420 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
@@ -18,7 +18,7 @@
*/
import { Injectable } from "@angular/core";
import { isEqual } from "lodash-es";
-import { ReplaySubject, Subject } from "rxjs";
+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";
@@ -38,10 +38,8 @@ const UI_PARAMETER_SYNC_DEBOUNCE_TIME_MS = 200;
/** Keeps Python UDF UI parameter structure in sync with the code editor and
workflow graph. */
@Injectable({ providedIn: "root" })
export class UiUdfParametersSyncService {
- private readonly uiParametersChangedSubject = new ReplaySubject<{
operatorId: string; parameters: UiUdfParameter[] }>(
- 1
- );
- private readonly uiParametersParseErrorSubject = new ReplaySubject<{
operatorId: string; message?: string }>(1);
+ private readonly uiParametersChangedSubject = new Subject<{ operatorId:
string; parameters: UiUdfParameter[] }>();
+ private readonly uiParametersParseErrorSubject = new Subject<{ operatorId:
string; message?: string }>();
/** Emits when parsed UI parameter structure changes; consumers should write
the parameters back to operatorProperties. */
readonly uiParametersChanged$ =
this.uiParametersChangedSubject.asObservable();