From b472718f6452f03d5adcfc0f45f6e2aea60d1c6f Mon Sep 17 00:00:00 2001 From: Kary Zheng <150742834+kz930@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:33:45 +0000 Subject: [PATCH] fix(visualization): define the render_error the table charts already call (#7260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? `TablesPlotOpDesc` and `FigureFactoryTableOpDesc` both generate a `TableChartOperator` that calls `self.render_error(...)` on two branches, but neither generated class defines that method. Each now defines it, in the same shape the other visualization operators use. ### Why are the changes needed? Both branches are reachable — an empty input table, and a value column left with only non-positive or null values. Executing each operator's generated module against an empty frame raises `AttributeError: 'TableChartOperator' object has no attribute 'render_error'` instead of showing the message the code was written to show. With the definition added, the same run yields `Tables Plot is not available. Reason is: input table is empty.` ### Any related issues, documentation, discussions? Closes #7244 ### How was this PR tested? `WorkflowOperator/scalafmtCheckAll` and both operators' descriptor specs (14 tests). Each spec now asserts its generated class defines `render_error` and formats the operator's name. Separately, each generated module was run locally against an empty frame before and after the fix, with the `pytexera` and `plotly` imports stubbed — the before run reproduces the `AttributeError`, the after run returns `Tables Plot is not available. Reason is: input table is empty.` ### Does this PR introduce any user-facing change? Yes. Those two cases now render the intended message instead of failing the operator. Nothing changes on the path that produces a chart. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5) --------- (backported from commit a96bf3e70c0e9d5bf72402637633b36e99049a25) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Xuan Gu <162244362+xuang7@users.noreply.github.com> --- .../FigureFactoryTableOpDesc.scala | 3 + .../tablesChart/TablesPlotOpDesc.scala | 3 + .../FigureFactoryTableOpDescSpec.scala | 113 ++++++++++++++++++ .../tablesChart/TablesPlotOpDescSpec.scala | 102 ++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDescSpec.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDescSpec.scala diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala index 2b61652f96e..edbb45236f6 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDesc.scala @@ -96,6 +96,9 @@ class FigureFactoryTableOpDesc extends PythonOperatorDescriptor { | |class TableChartOperator(UDFTableOperator): | + | def render_error(self, error_msg) -> str: + | return f"

Figure Factory Table is not available.

Reason is: {error_msg}

" + | | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: | | if table.empty: diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala index 2ff29fdc0d7..dd40832164a 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDesc.scala @@ -76,6 +76,9 @@ class TablesPlotOpDesc extends PythonOperatorDescriptor { |import plotly.io |class TableChartOperator(UDFTableOperator): | + | def render_error(self, error_msg) -> str: + | return f"

Tables Plot is not available.

Reason is: {error_msg}

" + | | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: | | if table.empty: diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDescSpec.scala new file mode 100644 index 00000000000..9d564fd3581 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableOpDescSpec.scala @@ -0,0 +1,113 @@ +/* + * 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.visualization.figureFactoryTable + +import org.scalatest.BeforeAndAfter +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class FigureFactoryTableOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { + + var opDesc: FigureFactoryTableOpDesc = _ + + before { + opDesc = new FigureFactoryTableOpDesc() + } + + private def b64(s: String): String = + Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8)) + + private def carries(output: String, name: String): Boolean = + output.contains(name) || output.contains(b64(name)) + + private def column(name: String): FigureFactoryTableConfig = { + val config = new FigureFactoryTableConfig() + config.attributeName = name + config + } + + private def withColumns(): Unit = + opDesc.columns = List(column("col_one"), column("col_two")) + + it should "throw AssertionError with a 'cannot be empty' message when columns list is empty (manipulateTable)" in { + val ex = intercept[AssertionError](opDesc.manipulateTable()) + ex.getMessage should not be null + ex.getMessage should include("cannot be empty") + } + + it should "throw AssertionError with a 'cannot be empty' message when columns list is empty (createFigureFactoryTablePlotlyFigure)" in { + val ex = intercept[AssertionError](opDesc.createFigureFactoryTablePlotlyFigure()) + ex.getMessage should not be null + ex.getMessage should include("cannot be empty") + } + + it should "throw AssertionError mentioning 'at least 30' when rowHeight is below 30" in { + withColumns() + opDesc.rowHeight = 10.0 + val ex = intercept[AssertionError](opDesc.createFigureFactoryTablePlotlyFigure()) + ex.getMessage should not be null + ex.getMessage should include("at least 30") + } + + it should "throw AssertionError mentioning 'non-negative' when fontSize is negative" in { + withColumns() + opDesc.fontSize = -1.0 + val ex = intercept[AssertionError](opDesc.createFigureFactoryTablePlotlyFigure()) + ex.getMessage should not be null + ex.getMessage should include("non-negative") + } + + it should "not throw with default fontSize (12) and rowHeight (30) once columns are set" in { + withColumns() + val plain = opDesc.createFigureFactoryTablePlotlyFigure().plain + assert(carries(plain, "col_one")) + assert(carries(plain, "col_two")) + plain should include("ff.create_table") + } + + it should "accept boundary values rowHeight = 30 and fontSize = 0" in { + withColumns() + opDesc.rowHeight = 30.0 + opDesc.fontSize = 0.0 + noException should be thrownBy opDesc.createFigureFactoryTablePlotlyFigure() + } + + it should "generate python code carrying the configured columns" in { + withColumns() + val code = opDesc.generatePythonCode() + assert(carries(code, "col_one")) + assert(carries(code, "col_two")) + code should include("class TableChartOperator(UDFTableOperator)") + } + + it should "define the render_error the empty-table branches call" in { + // Both empty-table branches call self.render_error; without the definition they + // raised AttributeError instead of rendering the message. + withColumns() + val code = opDesc.generatePythonCode() + code should include("def render_error(self, error_msg) -> str:") + code should include( + """return f"

Figure Factory Table is not available.

Reason is: {error_msg}

"""" + ) + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDescSpec.scala new file mode 100644 index 00000000000..c088cacc672 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesPlotOpDescSpec.scala @@ -0,0 +1,102 @@ +/* + * 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.visualization.tablesChart + +import org.scalatest.BeforeAndAfter +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class TablesPlotOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { + + var opDesc: TablesPlotOpDesc = _ + + before { + opDesc = new TablesPlotOpDesc() + } + + private def b64(s: String): String = + Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8)) + + private def carries(output: String, name: String): Boolean = + output.contains(name) || output.contains(b64(name)) + + private def column(name: String): TablesConfig = { + val config = new TablesConfig() + config.attributeName = name + config + } + + it should "throw AssertionError with a 'cannot be empty' message when included columns list is empty (manipulateTable)" in { + val ex = intercept[AssertionError](opDesc.manipulateTable()) + ex.getMessage should not be null + ex.getMessage should include("cannot be empty") + } + + it should "throw AssertionError with a 'cannot be empty' message when included columns list is empty (createPlotlyFigure)" in { + val ex = intercept[AssertionError](opDesc.createPlotlyFigure()) + ex.getMessage should not be null + ex.getMessage should include("cannot be empty") + } + + it should "render the configured columns when the included columns list is set" in { + opDesc.includedColumns = List(column("col_one"), column("col_two")) + val tablePlain = opDesc.manipulateTable().plain + assert(carries(tablePlain, "col_one")) + assert(carries(tablePlain, "col_two")) + + val figurePlain = opDesc.createPlotlyFigure().plain + assert(carries(figurePlain, "col_one")) + assert(carries(figurePlain, "col_two")) + figurePlain should include("go.Table") + } + + it should "generate python code carrying the configured columns" in { + opDesc.includedColumns = List(column("col_one"), column("col_two")) + val code = opDesc.generatePythonCode() + assert(carries(code, "col_one")) + assert(carries(code, "col_two")) + code should include("class TableChartOperator(UDFTableOperator)") + } + + it should "join multiple columns with a comma, not the literal ',' (valid Python)" in { + // Each column renders to a decode(...) call, so they must be comma-joined; + // joining with the literal ',' puts a string right after a call (invalid Python). + opDesc.includedColumns = List(column("col_one"), column("col_two")) + val code = opDesc.generatePythonCode() + code should include( + s"self.decode_python_template('${b64("col_one")}'),self.decode_python_template('${b64("col_two")}')" + ) + code should not include "')','" + } + + it should "define the render_error the empty-table branches call" in { + // Both empty-table branches call self.render_error; without the definition they + // raised AttributeError instead of rendering the message. + opDesc.includedColumns = List(column("col_one"), column("col_two")) + val code = opDesc.generatePythonCode() + code should include("def render_error(self, error_msg) -> str:") + code should include( + """return f"

Tables Plot is not available.

Reason is: {error_msg}

"""" + ) + } +}