From a0a3a65381997a71afc1d01ea4813b919be6c4ce Mon Sep 17 00:00:00 2001 From: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:09:26 -0800 Subject: [PATCH 1/6] added ternary contour op --- .../texera/amber/operator/LogicalOp.scala | 2 + .../ternaryContour/TernaryContourOpDesc.scala | 147 ++++++++++++++++++ .../assets/operator_images/TernaryContour.png | Bin 0 -> 6374 bytes 3 files changed, 149 insertions(+) create mode 100644 common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala create mode 100644 frontend/src/assets/operator_images/TernaryContour.png diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala index eb319a82d1d..caf1540de03 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala @@ -129,6 +129,7 @@ import org.apache.texera.amber.operator.visualization.sankeyDiagram.SankeyDiagra import org.apache.texera.amber.operator.visualization.scatter3DChart.Scatter3dChartOpDesc import org.apache.texera.amber.operator.visualization.scatterplot.ScatterplotOpDesc import org.apache.texera.amber.operator.visualization.tablesChart.TablesPlotOpDesc +import org.apache.texera.amber.operator.visualization.ternaryContour.TernaryContourOpDesc import org.apache.texera.amber.operator.visualization.ternaryPlot.TernaryPlotOpDesc import org.apache.texera.amber.operator.visualization.timeSeriesplot.TimeSeriesOpDesc import org.apache.texera.amber.operator.visualization.treeplot.TreePlotOpDesc @@ -242,6 +243,7 @@ trait StateTransferFunc new Type(value = classOf[TablesPlotOpDesc], name = "TablesPlot"), new Type(value = classOf[ContinuousErrorBandsOpDesc], name = "ContinuousErrorBands"), new Type(value = classOf[FigureFactoryTableOpDesc], name = "FigureFactoryTable"), + new Type(value = classOf[TernaryContourOpDesc], name = "TernaryContour"), new Type(value = classOf[TernaryPlotOpDesc], name = "TernaryPlot"), new Type(value = classOf[DendrogramOpDesc], name = "Dendrogram"), new Type(value = classOf[NestedTableOpDesc], name = "NestedTable"), diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala new file mode 100644 index 00000000000..2e9bde676aa --- /dev/null +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala @@ -0,0 +1,147 @@ +/* + * 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.ternaryContour + +import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} +import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} + +/** + * Visualization Operator for Ternary Plots. + * + * This operator uses three data fields to construct a ternary plot. + * The points can optionally be color coded using a data field. + */ + +class TernaryContourOpDesc extends PythonOperatorDescriptor { + + // Add annotations for the first variable + @JsonProperty(value = "firstVariable", required = true) + @JsonSchemaTitle("Variable 1") + @JsonPropertyDescription("First variable data field") + @AutofillAttributeName var firstVariable: String = "" + + // Add annotations for the second variable + @JsonProperty(value = "secondVariable", required = true) + @JsonSchemaTitle("Variable 2") + @JsonPropertyDescription("Second variable data field") + @AutofillAttributeName var secondVariable: String = "" + + // Add annotations for the third variable + @JsonProperty(value = "thirdVariable", required = true) + @JsonSchemaTitle("Variable 3") + @JsonPropertyDescription("Third variable data field") + @AutofillAttributeName var thirdVariable: String = "" + + // Add annotations for the fourth variable + @JsonProperty(value = "fourthVariable", required = true) + @JsonSchemaTitle("Variable 4") + @JsonPropertyDescription("Fourth variable data field") + @AutofillAttributeName var fourthVariable: String = "" + + // OperatorInfo instance describing ternary plot + override def operatorInfo: OperatorInfo = + OperatorInfo( + userFriendlyName = "Ternary Contour", + operatorDescription = "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", + operatorGroupName = OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP, + inputPorts = List(InputPort()), + outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) + ) + + override def getOutputSchemas( + inputSchemas: Map[PortIdentity, Schema] + ): Map[PortIdentity, Schema] = { + val outputSchema = Schema() + .add("html-content", AttributeType.STRING) + Map(operatorInfo.outputPorts.head.id -> outputSchema) + Map(operatorInfo.outputPorts.head.id -> outputSchema) + } + + /** Returns a Python string that drops any tuples with missing values */ + def manipulateTable(): String = { + // Check for any empty data field names + assert(firstVariable.nonEmpty && secondVariable.nonEmpty && thirdVariable.nonEmpty) + s""" + | # Remove any tuples that contain missing values + | table.dropna(subset=['$firstVariable', '$secondVariable', '$thirdVariable', '$fourthVariable'], inplace = True) + | + | #Remove rows where any of the first three variables are negative + | table = table[(table[['$firstVariable', '$secondVariable', '$thirdVariable']] >= 0).all(axis=1)] + | + | #Remove zero-sum rows + | s = table['$firstVariable'] + table['$secondVariable'] + table['$thirdVariable'] + | table = table[s > 0] + |""".stripMargin + } + + /** Returns a Python string that creates the ternary contour plot figure */ + def createPlotlyFigure(): String = { + s""" + | A = table['$firstVariable'].to_numpy() + | B = table['$secondVariable'].to_numpy() + | C = table['$thirdVariable'].to_numpy() + | Z = table['$fourthVariable'].to_numpy() + | fig = ff.create_ternary_contour(np.array([A,B,C]), Z, pole_labels=['$firstVariable', '$secondVariable', '$thirdVariable'], interp_mode='cartesian') + |""".stripMargin + } + + /** Returns a Python string that yields the html content of the ternary contour plot */ + override def generatePythonCode(): String = { + val finalCode = + s""" + |from pytexera import * + | + |import plotly.express as px + |import plotly.io + |import plotly.figure_factory as ff + |import numpy as np + | + |class ProcessTableOperator(UDFTableOperator): + | + | # Generate custom error message as html string + | def render_error(self, error_msg): + | return '''

TernaryContour is not available.

+ |

Reasons are: {}

+ | '''.format(error_msg) + | + | @overrides + | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: + | if table.empty: + | yield {'html-content': self.render_error("Input table is empty.")} + | return + | ${manipulateTable()} + | if table.empty: + | yield {'html-content': self.render_error("No valid rows left (every row has at least 1 missing value).")} + | return + | ${createPlotlyFigure()} + | # Convert fig to html content + | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) + | yield {'html-content':html} + |""".stripMargin + finalCode + } + +} diff --git a/frontend/src/assets/operator_images/TernaryContour.png b/frontend/src/assets/operator_images/TernaryContour.png new file mode 100644 index 0000000000000000000000000000000000000000..ba0e8c3ec23d6dc2260b628e5aee5b672bbd2bf8 GIT binary patch literal 6374 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 literal 0 HcmV?d00001 From 0b8451d88901718b8b6dfd9576e6dd54341d32ad Mon Sep 17 00:00:00 2001 From: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:50:35 -0800 Subject: [PATCH 2/6] scala format fix --- .../visualization/ternaryContour/TernaryContourOpDesc.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala index 2e9bde676aa..1b5b39a293f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala @@ -65,7 +65,8 @@ class TernaryContourOpDesc extends PythonOperatorDescriptor { override def operatorInfo: OperatorInfo = OperatorInfo( userFriendlyName = "Ternary Contour", - operatorDescription = "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", + operatorDescription = + "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", operatorGroupName = OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP, inputPorts = List(InputPort()), outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) From cfdad432c3eb1edaea46ad6f50ce626c5db0d244 Mon Sep 17 00:00:00 2001 From: carloea2 Date: Mon, 9 Feb 2026 01:54:47 -0600 Subject: [PATCH 3/6] feat(backend): introduce python code template builder for creating Python based operators (#4189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changes were proposed in this PR? This new PR introduces a PythonTemplateBuilder mechanism to create Texera’s Python native operators. It refactors how Python code is created using a new template concept, addressing prior issues with string formatting. Previously, creating Python-based operators is via raw string formatting, which is fragile: user text can contain `{}`, `%`, quotes, or newlines that break formatting. This PR makes codegen deterministic and safer by treating interpolated values as data segments. #### Design **Diagram 1** (compile-time `pyb` expansion and validation) This diagram describes the Scala compile-time flow when a developer writes a `pyb"..."` template: the `pyb` macro receives the literal parts and argument trees, verifies that literal segments are safe, classifies each interpolated argument (plain text vs. encodable vs. nested builder), and applies boundary validation to ensure encodable content cannot “break out” of its intended Python context. Each argument is evaluated once, runtime guards are injected when a nested builder is spliced in, and the pieces are concatenated into a `PythonTemplateBuilder`, which compacts adjacent text chunks and renders an `encode()` output where encodable values become decode-at-runtime segments before the generated Python is embedded into the operator payload. ```mermaid sequenceDiagram participant Dev as Scala code participant SC as StringContext participant M as pyb macro participant EI as EncodableInspector participant BV as BoundaryValidator participant PTB as PythonTemplateBuilder Dev->>SC: pyb"t0 $a0 t1 $a1 t2" SC->>M: parts + arg trees M->>M: verify literal parts M->>EI: classify args loop each direct encodable arg M->>BV: validateCompileTime(left,right,prefixLine) BV-->>M: ok / abort end M->>M: eval each arg once into __pyb_argN loop each nested builder arg M->>BV: runtimeChecksForNestedBuilder(ctx,__pyb_argN) BV-->>M: injected guard if unsafe end M->>PTB: concat parts + __pyb_argN PTB-->>Dev: returns PythonTemplateBuilder PTB->>PTB: compact adjacent Text chunks PTB->>PTB: render Encode (encodable -> decode(base64)) PTB-->>Dev: encode() returns python source string Dev->>Dev: embed generated python into operator payload ``` **Diagram 2** (end-to-end runtime flow: UI → descriptor → worker decoding with cache) This diagram illustrates the end-to-end pipeline from UI input to execution: the UI submits parameters (including user-controlled strings) to the Scala descriptor, where `pyb` expansion and `PythonTemplateBuilder` assembly produce a deterministic Python source string in “encode mode.” The encoded Python is embedded into the workflow plan payload, dispatched by the workflow service to the Python worker, and executed by the operator; during execution, the operator uses `PythonTemplateDecoder` to recover user text by decoding each encoded segment. An LRU cache (size 256) backs the decoder so repeated encoded strings decode once and subsequently reuse cached UTF-8 strings, reducing overhead while preserving strict decoding semantics. ```mermaid sequenceDiagram autonumber participant UI as UI Web participant DESC as Descriptor (Scala) participant MAC as pyb macro (compile time) participant PTB as PythonTemplateBuilder participant PLAN as Plan payload participant SVC as Workflow service participant WK as Python worker participant OP as Python Operator participant DEC as PythonTemplateDecoder participant CACHE as lru_cache 256 note over DESC,PTB: PyB related (Scala compile time codegen) UI->>DESC: submit params + code strings DESC->>MAC: pyb interpolation expands MAC-->>DESC: expanded builder + validation logic DESC->>PTB: assemble chunks (Text + Value) PTB-->>DESC: rendered python source (encode mode) note over DESC,WK: Plan + dispatch DESC->>PLAN: embed python source into payload PLAN->>SVC: submit workflow plan SVC->>WK: dispatch operator payload note over WK,DEC: Python runtime (worker executes generated source) WK->>OP: start operator with python source loop each encoded segment OP->>DEC: decode(base64) DEC->>CACHE: lookup(base64) alt cache hit CACHE-->>DEC: cached str else cache miss CACHE-->>DEC: miss DEC->>DEC: base64 decode + utf8 strict DEC->>CACHE: store(base64,str) end DEC-->>OP: recovered user text end OP-->>WK: execution continued ``` **Diagram 3** (test harness: generate code, reject raw-invalid, `py_compile`) This diagram shows the automated verification path for Python native operators: ScalaTest uses ClassGraph to discover every `PythonOperatorDescriptor`, instantiates each descriptor, inject invalid raw strings into class fields marked with `Json` properties and calls `generatePythonCode()` to produce the final Python source string. The test asserts that no “RawInvalid” marker appears in the generated output (indicating unsafe raw text did not leak), writes the source to a temporary `source.py`, and runs `python -m py_compile` to ensure the code is syntactically valid and compilable. Any raw-invalid leakage, compile error, or timeout causes the test to fail, enforcing consistent template-based code generation across operators. ```mermaid sequenceDiagram autonumber participant TS as ScalaTest participant CG as ClassGraph scanner participant DESC as PythonOperatorDescriptor participant GEN as generatePythonCode participant SPEC as PythonCodeRawInvalidTextSpec participant PY as python -m py_compile participant FS as temp file (source.py) TS->>CG: scan descriptors in packages CG-->>TS: list of PythonOperatorDescriptor classes loop each descriptor class TS->>DESC: instantiate descriptor TS->>GEN: call generatePythonCode(descriptor) GEN-->>TS: python source string TS->>SPEC: assert RawInvalid marker not present alt marker leaked SPEC-->>TS: FAIL (invalid raw text leaked) else marker clean SPEC-->>TS: OK TS->>FS: write source to temp file TS->>PY: py_compile(temp file) alt compile error or timeout PY-->>TS: FAIL (compile/timeout) else compile ok PY-->>TS: PASS end end end ``` #### As a developer, how to use `pyb` to create your python-based operators 1. **Use `EncodableString` for any UI/user-controlled text** Before (raw `String`) ```scala @JsonSchemaTitle("Ground Truth Attribute Column") @AutofillAttributeName var groundTruthAttribute: String = "" @JsonSchemaTitle("Selected Features") @AutofillAttributeNameList var selectedFeatures: List[String] = _ ``` After (`EncodableString`) ```scala import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString @JsonSchemaTitle("Ground Truth Attribute Column") @AutofillAttributeName var groundTruthAttribute: EncodableString = "" @JsonSchemaTitle("Selected Features") @AutofillAttributeNameList var selectedFeatures: List[EncodableString] = _ ``` --- 2. **Write Python using `pyb"""..."""` and interpolate values with `$param`** Before (string interpolation with manual quoting) ```scala val code = s""" |y_train = self.dataset[\"$groundTruthAttribute\"] |""".stripMargin ``` After (template + data: no manual quoting) ```scala import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext val code = pyb""" |y_train = self.dataset[$groundTruthAttribute] |""".encode //Automatic stripMargin applied inside the builder ``` --- 3. **For optional arguments, represent them as small `pyb` fragments, then put them in the code template** Before (manual string concatenation + quote juggling) ```scala val colorArg = if (color.nonEmpty) s", color='$color'" else "" val patternArg = if (pattern.nonEmpty) s", pattern_shape='$pattern'" else "" val fig = s"fig = px.timeline(table, x_start='start', x_end='finish', y='task'$colorArg$patternArg)" ``` After (optional fragments are builders too) ```scala val colorArg = if (color.nonEmpty) pyb", color=$color" else pyb""" val patternArg = if (pattern.nonEmpty) pyb", pattern_shape=$pattern" else pyb""" val fig = pyb"""fig = px.timeline(table, x_start=$start, x_end=$finish, y=$task$colorArg$patternArg)""" ``` --- 4. **Return `.encode` from `generatePythonCode()`** Before (returns raw string) ```scala override def generatePythonCode(): String = { val finalCode = s""" |from pytexera import * |y_train = self.dataset[\"$groundTruthAttribute\"] |""".stripMargin finalCode } ``` After (returns encoded output from builder) ```scala override def generatePythonCode(): String = { val finalCode = pyb""" |from pytexera import * |y_train = self.dataset[$groundTruthAttribute] |""" finalCode.encode } ``` --- 5. **Try to avoid the use of `s"..."`, `.format`, or `%` formatting for Python codegen** Before (`s` / `String.format` / `.format` patterns) ```scala // s"..." return s"""table[\"${ele.attribute}\"].values.shape[0]""" // String.format / "{}" placeholders workflowParam = workflowParam + String.format("%s = {},", ele.parameter.getName) portParam = portParam + String.format("%s(table['%s'].values[i]),", ele.parameter.getType, ele.attribute) ``` After (`pyb` templates end-to-end) ```scala return pyb"""table[${ele.attribute}].values.shape[0]""" workflowParam = pyb"$workflowParam${ele.parameter.getName} = {}," portParam = pyb"$portParam${ele.parameter.getType}(table[${ele.attribute}].values[i])," ``` --- 6. **Develop the unit tests in the new way** Before (expects quoted literals like `'start'`) ```scala assert( opDesc.createPlotlyFigure().plain.contains( "fig = px.timeline(table, x_start='start', x_end='finish', y='task' , color='color' )" ) ) ``` After (expects template output using variables, no embedded quotes) ```scala assert( opDesc.createPlotlyFigure().plain.contains( "fig = px.timeline(table, x_start=start, x_end=finish, y=task , color=color )" ) ) ``` ### Any related issues, documentation, discussions? No ### How was this PR tested? The PR includes a comprehensive set of tests to ensure the new functionality works and that it doesn’t break existing workflows: Unit Tests for PythonTemplateBuilder: New unit tests were added to verify that PythonTemplateBuilder correctly classifies and encodes segments. For example, tests likely feed in code strings with various edge cases (braces, percentage signs, quotes, etc.) and assert that the builder produces the expected spec output. Unit Tests for PythonCodeRawInvalidTextSpec: 2 new unit test to instantiate each Python Native Operator, and call `generatePythonCode` method and checks the python code compiles and the string format is consistent. ## Was this PR authored or co-authored using generative AI tooling? Reviewed by ChatGPT 5.2 --- .github/workflows/github-action-build.yml | 6 + amber/src/main/python/core/models/operator.py | 45 +- build.sbt | 9 +- common/pybuilder/build.sbt | 73 ++ .../amber/pybuilder/BoundaryValidator.scala | 187 ++++ .../amber/pybuilder/EncodableInspector.scala | 162 ++++ .../pybuilder/EncodableStringAnnotation.java | 34 + .../amber/pybuilder/PythonLexerUtils.scala | 84 ++ .../pybuilder/PythonTemplateBuilder.scala | 481 ++++++++++ .../pybuilder/PythonLexerUtilsSpec.scala | 167 ++++ .../pybuilder/PythonTemplateBuilderSpec.scala | 598 ++++++++++++ common/workflow-operator/build.sbt | 2 + ...gingFaceIrisLogisticRegressionOpDesc.scala | 23 +- .../HuggingFaceSentimentAnalysisOpDesc.scala | 18 +- .../HuggingFaceSpamSMSDetectionOpDesc.scala | 18 +- .../HuggingFaceTextSummarizationOpDesc.scala | 14 +- .../Scorer/MachineLearningScorerOpDesc.scala | 20 +- .../base/HyperParameters.scala | 5 +- .../base/SklearnAdvancedBaseDesc.scala | 62 +- .../sklearn/SklearnClassifierOpDesc.scala | 16 +- .../SklearnLinearRegressionOpDesc.scala | 12 +- .../sklearn/SklearnPredictionOpDesc.scala | 22 +- .../training/SklearnTrainingOpDesc.scala | 16 +- .../operator/sort/SortCriteriaUnit.scala | 3 +- .../amber/operator/sort/SortOpDesc.scala | 7 +- .../reddit/RedditSearchSourceOpDesc.scala | 26 +- .../timeSeriesPlot/TimeSeriesPlot.scala | 30 +- .../visualization/DotPlot/DotPlotOpDesc.scala | 21 +- .../IcicleChart/IcicleChartOpDesc.scala | 31 +- .../ImageViz/ImageVisualizerOpDesc.scala | 19 +- .../ScatterMatrixChartOpDesc.scala | 23 +- .../barChart/BarChartOpDesc.scala | 33 +- .../boxViolinPlot/BoxViolinPlotOpDesc.scala | 35 +- .../bubbleChart/BubbleChartOpDesc.scala | 39 +- .../bulletChart/BulletChartOpDesc.scala | 22 +- .../BulletChartStepDefinition.scala | 5 +- .../CandlestickChartOpDesc.scala | 26 +- .../choroplethMap/ChoroplethMapOpDesc.scala | 31 +- .../continuousErrorBands/BandConfig.scala | 7 +- .../ContinuousErrorBandsOpDesc.scala | 47 +- .../contourPlot/ContourPlotOpDesc.scala | 28 +- .../dendrogram/DendrogramOpDesc.scala | 33 +- .../dumbbellPlot/DumbbellDotConfig.scala | 3 +- .../dumbbellPlot/DumbbellPlotOpDesc.scala | 49 +- .../FigureFactoryTableConfig.scala | 3 +- .../FigureFactoryTableOpDesc.scala | 55 +- .../filledAreaPlot/FilledAreaPlotOpDesc.scala | 55 +- .../funnelPlot/FunnelPlotOpDesc.scala | 40 +- .../ganttChart/GanttChartOpDesc.scala | 41 +- .../gaugeChart/GaugeChartOpDesc.scala | 22 +- .../gaugeChart/GaugeChartSteps.scala | 5 +- .../visualization/heatMap/HeatMapOpDesc.scala | 23 +- .../hierarchychart/HierarchyChartOpDesc.scala | 31 +- .../hierarchychart/HierarchySection.scala | 3 +- .../histogram/HistogramChartOpDesc.scala | 45 +- .../histogram2d/Histogram2DOpDesc.scala | 28 +- .../lineChart/LineChartOpDesc.scala | 37 +- .../visualization/lineChart/LineConfig.scala | 9 +- .../nestedTable/NestedTableConfig.scala | 7 +- .../nestedTable/NestedTableOpDesc.scala | 18 +- .../networkGraph/NetworkGraphOpDesc.scala | 34 +- .../pieChart/PieChartOpDesc.scala | 31 +- .../quiverPlot/QuiverPlotOpDesc.scala | 29 +- .../rangeSlider/RangeSliderOpDesc.scala | 39 +- .../sankeyDiagram/SankeyDiagramOpDesc.scala | 75 +- .../scatter3DChart/Scatter3dChartOpDesc.scala | 61 +- .../scatterplot/ScatterplotOpDesc.scala | 51 +- .../stripChart/StripChartOpDesc.scala | 40 +- .../tablesChart/TablesConfig.scala | 3 +- .../tablesChart/TablesPlotOpDesc.scala | 42 +- .../ternaryPlot/TernaryPlotOpDesc.scala | 39 +- .../treeplot/TreeplotOpDesc.scala | 12 +- .../volcanoPlot/VolcanoPlotOpDesc.scala | 20 +- .../waterfallChart/WaterfallChartOpDesc.scala | 23 +- .../wordCloud/WordCloudOpDesc.scala | 31 +- .../timeSeriesPlot/TimeSeriesOpDescSpec.scala | 44 + .../DotPlot/DotPlotOpDescSpec.scala | 3 +- .../barChart/BarChartOpDescSpec.scala | 2 +- .../bubbleChart/BubbleChartOpDescSpec.scala | 3 +- .../ganttChart/GanttChartOpDescSpec.scala | 15 +- .../HierarchyChartOpDescSpec.scala | 2 - .../amber/pybuilder/DescriptorChecker.scala | 902 ++++++++++++++++++ .../pybuilder/PythonClassgraphScanner.scala | 56 ++ .../pybuilder/PythonConsoleCapture.scala | 44 + .../PythonRawTextReportRenderer.scala | 53 + .../pybuilder/PythonReflectionTextUtils.scala | 64 ++ .../pybuilder/PythonReflectionUtils.scala | 65 ++ .../util/PythonCodeRawInvalidTextSpec.scala | 266 ++++++ 88 files changed, 4268 insertions(+), 795 deletions(-) create mode 100644 common/pybuilder/build.sbt create mode 100644 common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala create mode 100644 common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala create mode 100644 common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableStringAnnotation.java create mode 100644 common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonLexerUtils.scala create mode 100644 common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala create mode 100644 common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonLexerUtilsSpec.scala create mode 100644 common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/DescriptorChecker.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonClassgraphScanner.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonConsoleCapture.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonRawTextReportRenderer.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionTextUtils.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionUtils.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/util/PythonCodeRawInvalidTextSpec.scala diff --git a/.github/workflows/github-action-build.yml b/.github/workflows/github-action-build.yml index 4c79fdce800..646ce4a119f 100644 --- a/.github/workflows/github-action-build.yml +++ b/.github/workflows/github-action-build.yml @@ -104,6 +104,12 @@ jobs: with: distribution: 'temurin' java-version: 11 + - name: Setup Python for Scala tests + uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Show Python + run: python --version || python3 --version - name: Setup sbt launcher uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1.1.14 - uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # v6.4.9 diff --git a/amber/src/main/python/core/models/operator.py b/amber/src/main/python/core/models/operator.py index 4d3288c67a5..79050839958 100644 --- a/amber/src/main/python/core/models/operator.py +++ b/amber/src/main/python/core/models/operator.py @@ -17,20 +17,63 @@ import overrides import pandas +from functools import lru_cache from abc import ABC, abstractmethod from collections import defaultdict -from typing import Iterator, List, Mapping, Optional, Union, MutableMapping +from typing import Iterator, List, Mapping, Optional, Union, MutableMapping, Protocol from . import Table, TableLike, Tuple, TupleLike, Batch, BatchLike from .state import State from .table import all_output_to_tuple +import base64 + class Operator(ABC): """ Abstract base class for all operators. """ + class PythonTemplateDecoder: + class Decoder(Protocol): + """Pluggable base64 decoder interface.""" + + def to_str(self, data: Union[str, bytes]) -> str: ... + + class StdlibBase64Decoder: + """Default decoder using Python's stdlib base64.""" + + def to_str(self, data: Union[str, bytes]) -> str: + b64_bytes = data.encode("ascii") if isinstance(data, str) else data + raw = base64.b64decode(b64_bytes, validate=False) + return raw.decode("utf-8", errors="strict") + + def __init__( + self, + decoder: Optional["Operator.PythonTemplateDecoder.Decoder"] = None, + cache_size: int = 256, + ) -> None: + self._decoder = decoder or self.StdlibBase64Decoder() + self._decode_cached = self._build_cached_decoder(cache_size) + + def _build_cached_decoder(self, cache_size: int): + @lru_cache(maxsize=cache_size) + def _cached(data: Union[str, bytes]) -> str: + return self._decoder.to_str(data) + + return _cached + + def decode(self, data: Union[str, bytes]) -> str: + return self._decode_cached(data) + + def _get_template_decoder(self) -> "Operator.PythonTemplateDecoder": + if not hasattr(self, "_python_template_decoder"): + self._python_template_decoder = self.PythonTemplateDecoder(cache_size=256) + return self._python_template_decoder + + def decode_python_template(self, data: Union[str, bytes]) -> str: + return self._get_template_decoder().decode(data) + __internal_is_source: bool = False @property diff --git a/build.sbt b/build.sbt index 027775ff253..1e506e44f60 100644 --- a/build.sbt +++ b/build.sbt @@ -37,8 +37,15 @@ lazy val AccessControlService = (project in file("access-control-service")) ) .configs(Test) .dependsOn(DAO % "test->test", Auth % "test->test") + +//This Scala module defines a pyb"..." macro-based DSL for composing Python code templates as an immutable PythonTemplateBuilder. +//Used mainly for Python Native Operators +lazy val PyBuilder = (project in file("common/pybuilder")) + .configs(Test) + .dependsOn(DAO % "test->test") // test scope dependency + lazy val WorkflowCore = (project in file("common/workflow-core")) - .dependsOn(DAO, Config) + .dependsOn(DAO, Config, PyBuilder) .configs(Test) .dependsOn(DAO % "test->test") // test scope dependency lazy val ComputingUnitManagingService = (project in file("computing-unit-managing-service")) diff --git a/common/pybuilder/build.sbt b/common/pybuilder/build.sbt new file mode 100644 index 00000000000..ea17dec30cd --- /dev/null +++ b/common/pybuilder/build.sbt @@ -0,0 +1,73 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import scala.collection.Seq +///////////////////////////////////////////////////////////////////////////// +// Project Settings +///////////////////////////////////////////////////////////////////////////// + +name := "pybuilder" +organization := "org.apache" +version := "1.0.0" + +scalaVersion := "2.13.12" + +enablePlugins(JavaAppPackaging) + +// Enable semanticdb for Scalafix +ThisBuild / semanticdbEnabled := true +ThisBuild / semanticdbVersion := scalafixSemanticdb.revision + +// Manage dependency conflicts by always using the latest revision +ThisBuild / conflictManager := ConflictManager.latestRevision + +// Restrict parallel execution of tests to avoid conflicts +Global / concurrentRestrictions += Tags.limit(Tags.Test, 1) + + +///////////////////////////////////////////////////////////////////////////// +// Compiler Options +///////////////////////////////////////////////////////////////////////////// + +// Scala compiler options +Compile / scalacOptions ++= Seq( + "-Xelide-below", "WARNING", // Turn on optimizations with "WARNING" as the threshold + "-feature", // Check feature warnings + "-deprecation", // Check deprecation warnings + "-Ywarn-unused:imports" // Check for unused imports +) + +///////////////////////////////////////////////////////////////////////////// +// Test-related Dependencies +///////////////////////////////////////////////////////////////////////////// + +libraryDependencies ++= Seq( + "org.scalamock" %% "scalamock" % "5.2.0" % Test, // ScalaMock + "org.scalatest" %% "scalatest" % "3.2.15" % Test, // ScalaTest + "junit" % "junit" % "4.13.2" % Test, // JUnit + "com.novocode" % "junit-interface" % "0.11" % Test, // SBT interface for JUnit + "io.github.classgraph" % "classgraph" % "4.8.184" % Test, + "org.scala-lang" % "scala-compiler" % scalaVersion.value % Test + +) + +///////////////////////////////////////////////////////////////////////////// +// Reflection-related Dependencies +///////////////////////////////////////////////////////////////////////////// +libraryDependencies ++= Seq( + "org.scala-lang" % "scala-reflect" % scalaVersion.value +) diff --git a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala new file mode 100644 index 00000000000..8475661d733 --- /dev/null +++ b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/BoundaryValidator.scala @@ -0,0 +1,187 @@ +/* + * 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.pybuilder + +import scala.reflect.macros.blackbox + +/** + * Macro-only helper: validates boundaries for Encodable insertions. + * + * Compile-time: abort with good messages for direct Encodable args. + * Runtime: for nested builders (unknown content at compile time), generate a check that throws if the builder contains Encodable chunks. + */ +final class BoundaryValidator[C <: blackbox.Context](val c: C) { + import PythonLexerUtils._ + import c.universe._ + + /** + * Centralized, templatized error messages (Option A). + * + * NOTE: This object lives inside the class so it can freely use string templates + * without any macro-context type gymnastics. + */ + private object BoundaryErrors { + + // Provide a hint that can differ between compile-time and runtime wording. + sealed trait RendererHint { def text: String } + + case object CompileTimeHint extends RendererHint { + override val text: String = + "EncodableString renders as a Python expression (self.PythonTemplateDecoder.decode(...))" + } + + case object RuntimeHint extends RendererHint { + override val text: String = + "EncodableString renders as a Python expression (self...decode(...))" + } + + private def prefix(argNum1Based: Int): String = + s"pyb interpolator: @EncodableStringAnnotation argument #$argNum1Based " + + def insideQuoted(argNum1Based: Int, hint: RendererHint): String = + prefix(argNum1Based) + + "appears inside a quoted Python string literal. " + + s"${hint.text}, so it must not be placed inside quotes." + + def afterComment(argNum1Based: Int): String = + prefix(argNum1Based) + + "appears after a '#' comment marker on the same line." + + def badLeftNeighbor(argNum1Based: Int, ch: Char): String = + prefix(argNum1Based) + + s"must not be immediately adjacent to '$ch' on the left. " + + "Add whitespace or punctuation to separate tokens." + + def badRightNeighbor(argNum1Based: Int, ch: Char): String = + prefix(argNum1Based) + + s"must not be immediately adjacent to '$ch' on the right. " + + "Add whitespace or punctuation to separate tokens." + } + + final case class CompileTimeContext( + leftPart: String, + rightPart: String, + prefixSource: String, + argIndex: Int, + errorPos: Position + ) + + final case class RuntimeContext( + leftPart: String, + rightPart: String, + prefixSource: String, + argIndex: Int + ) + + def validateCompileTime(ctx: CompileTimeContext): Unit = { + val prefixLine = lineTail(ctx.prefixSource) + val argNum = ctx.argIndex + 1 + + if (hasUnclosedQuote(prefixLine)) { + c.abort( + ctx.errorPos, + BoundaryErrors.insideQuoted(argNum, BoundaryErrors.CompileTimeHint) + ) + } + + if (hasCommentOutsideQuotes(prefixLine)) { + c.abort( + ctx.errorPos, + BoundaryErrors.afterComment(argNum) + ) + } + + if (ctx.leftPart.nonEmpty) { + val leftNeighbor = ctx.leftPart.charAt(ctx.leftPart.length - 1) + if (isBadNeighbor(leftNeighbor)) { + c.abort( + ctx.errorPos, + BoundaryErrors.badLeftNeighbor(argNum, leftNeighbor) + ) + } + } + + if (ctx.rightPart.nonEmpty) { + val rightNeighbor = ctx.rightPart.charAt(0) + if (isBadNeighbor(rightNeighbor)) { + c.abort( + ctx.errorPos, + BoundaryErrors.badRightNeighbor(argNum, rightNeighbor) + ) + } + } + } + + /** + * Generate runtime checks for nested PythonTemplateBuilder args. + * + * This is only emitted when the boundary context is unsafe. The runtime guard is: + * if (arg.containsEncodableString) throw ... + */ + def runtimeChecksForNestedBuilder(ctx: RuntimeContext, argIdent: Tree): List[Tree] = { + val prefixLine = lineTail(ctx.prefixSource) + val argNum = ctx.argIndex + 1 + + val insideQuoted = hasUnclosedQuote(prefixLine) + val afterComment = hasCommentOutsideQuotes(prefixLine) + + val leftNeighborOpt: Option[Char] = + if (ctx.leftPart.nonEmpty) Some(ctx.leftPart.charAt(ctx.leftPart.length - 1)) else None + + val rightNeighborOpt: Option[Char] = + if (ctx.rightPart.nonEmpty) Some(ctx.rightPart.charAt(0)) else None + + val throwStmts = List.newBuilder[Tree] + + if (insideQuoted) { + val msg = BoundaryErrors.insideQuoted(argNum, BoundaryErrors.RuntimeHint) + throwStmts += q"throw new IllegalArgumentException(${Literal(Constant(msg))})" + } + + if (afterComment) { + val msg = BoundaryErrors.afterComment(argNum) + throwStmts += q"throw new IllegalArgumentException(${Literal(Constant(msg))})" + } + + leftNeighborOpt.foreach { ch => + if (isBadNeighbor(ch)) { + val msg = BoundaryErrors.badLeftNeighbor(argNum, ch) + throwStmts += q"throw new IllegalArgumentException(${Literal(Constant(msg))})" + } + } + + rightNeighborOpt.foreach { ch => + if (isBadNeighbor(ch)) { + val msg = BoundaryErrors.badRightNeighbor(argNum, ch) + throwStmts += q"throw new IllegalArgumentException(${Literal(Constant(msg))})" + } + } + + val throws = throwStmts.result() + if (throws.isEmpty) Nil + else { + List(q""" + if ($argIdent.containsEncodableString) { + ..$throws + } + """) + } + } +} diff --git a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala new file mode 100644 index 00000000000..58bdcb649ec --- /dev/null +++ b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableInspector.scala @@ -0,0 +1,162 @@ +/* + * 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.pybuilder + +import scala.reflect.macros.blackbox + +/** + * Macro-only helper: inspects argument trees / types / symbols to decide if a value is Encodable-marked. + * + * NOTE: This must be context-bound because Tree/Type/Annotation are from `c.universe`. + */ +final class EncodableInspector[C <: blackbox.Context](val c: C) { + + import c.universe._ + + private val stringRendererTpe: Type = + typeOf[ + PythonTemplateBuilder.StringRenderer + ] + + private val pythonTemplateBuilderTpe: Type = + typeOf[PythonTemplateBuilder] + + // Previous/original approach: direct encodable args include values already wrapped as EncodableStringRenderer + private val encodableStringRendererTpe: Type = + typeOf[ + PythonTemplateBuilder.EncodableStringRenderer + ] + + // Keep this as a string so it also works if the annotation is referenced indirectly. + private val encodableStringAnnotationFqn = + "org.apache.texera.amber.EncodableStringAnn" + + /** + * If we are pointing at a getter/accessor, hop to its accessed field symbol when possible. + * + * Why: Many annotations are placed on constructor params/fields, but call sites see the accessor. + */ + private def safeAccessed(sym: Symbol): Symbol = + sym match { + case termAccessor: TermSymbol if termAccessor.isAccessor => termAccessor.accessed + case methodAccessor: MethodSymbol if methodAccessor.isAccessor => methodAccessor.accessed + case _ => sym + } + + /** True if an annotation instance is @EncodableStringAnn. */ + private def annIsEncodableString(annotation: Annotation): Boolean = { + val annotationType = annotation.tree.tpe + annotationType != null && ( + annotationType.typeSymbol.fullName == encodableStringAnnotationFqn || + (annotationType <:< typeOf[EncodableStringAnnotation]) + ) + } + + /** + * True if a [[Type]] carries @EncodableStringAnnotation as a TYPE_USE annotation (via [[AnnotatedType]]). + * + * Walks common wrappers (existentials, refinements, type refs) to find nested annotations. + */ + private def typeHasEncodableString(typeToCheck: Type): Boolean = { + def loop(t: Type): Boolean = { + if (t == null) false + else { + val widened = t.dealias.widen + widened match { + case AnnotatedType(anns, underlying) => + anns.exists(annIsEncodableString) || loop(underlying) + case ExistentialType(_, underlying) => + loop(underlying) + case RefinedType(parents, _) => + parents.exists(loop) + case TypeRef(_, _, args) => + args.exists(loop) + case other => + val sym = other.typeSymbol + val symHasAnn = + sym != null && sym != NoSymbol && sym.annotations.exists(annIsEncodableString) + symHasAnn || other.typeArgs.exists(loop) + } + } + } + + loop(typeToCheck) + } + + /** + * Checks @EncodableStringAnnotation on either: + * - accessed symbol (field/param), or + * - type (TYPE_USE), via [[AnnotatedType]]. + */ + def treeHasEncodableString(tree: Tree): Boolean = { + val rawSym = tree.symbol + val symHasAnn = + rawSym != null && rawSym != NoSymbol && { + val accessed = safeAccessed(rawSym) + accessed != null && accessed != NoSymbol && accessed.annotations.exists(annIsEncodableString) + } + + symHasAnn || (tree.tpe != null && typeHasEncodableString(tree.tpe)) + } + + def isPythonTemplateBuilderArg(argExpr: c.Expr[Any]): Boolean = { + val tpe = argExpr.tree.tpe + tpe != null && (tpe.dealias.widen <:< pythonTemplateBuilderTpe) + } + + def isStringRendererArg(argExpr: c.Expr[Any]): Boolean = { + val tpe = argExpr.tree.tpe + tpe != null && (tpe.dealias.widen <:< stringRendererTpe) + } + + /** True if the arg is Encodable (direct argument, not a nested builder). */ + def isDirectEncodableStringArg(argExpr: c.Expr[Any]): Boolean = { + if (isPythonTemplateBuilderArg(argExpr)) false + else { + val tpe = argExpr.tree.tpe + // Previous/original behavior: + // - treat already-wrapped EncodableStringRenderer as encodable + // - OR detect @EncodableStringAnnotation on symbol/type + (tpe != null && (tpe.dealias.widen <:< encodableStringRendererTpe)) || + treeHasEncodableString(argExpr.tree) + } + } + + /** + * Wrap an argument expression as a [[PythonTemplateBuilder.StringRenderer]] AST node. + * + * Priority: + * 1) If it's already a StringRenderer, keep it (cast). + * 2) Else if Encodable-marked, wrap as EncodableStringRenderer. + * 3) Else wrap as PyLiteralStringRenderer. + */ + def wrapArg(argExpr: c.Expr[Any]): Tree = { + val argTree = argExpr.tree + val argType = argTree.tpe + + if (argType != null && (argType.dealias.widen <:< stringRendererTpe)) { + q"$argTree.asInstanceOf[_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder.StringRenderer]" + } else if (treeHasEncodableString(argTree)) { + q"_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder.EncodableStringRenderer($argTree.toString)" + } else { + q"_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PyLiteralStringRenderer($argTree.toString)" + } + } +} diff --git a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableStringAnnotation.java b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableStringAnnotation.java new file mode 100644 index 00000000000..ea17e6d0130 --- /dev/null +++ b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/EncodableStringAnnotation.java @@ -0,0 +1,34 @@ +/* + * 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.pybuilder; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ + ElementType.FIELD, + ElementType.PARAMETER, + ElementType.TYPE_USE, + ElementType.LOCAL_VARIABLE +}) +public @interface EncodableStringAnnotation {} \ No newline at end of file diff --git a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonLexerUtils.scala b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonLexerUtils.scala new file mode 100644 index 00000000000..08aac3a9e8a --- /dev/null +++ b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonLexerUtils.scala @@ -0,0 +1,84 @@ +/* + * 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.pybuilder + +/** + * Pure helpers used by the macro for quick, best-effort Python lexical checks. + * + * These are intentionally *not* macro-dependent, so they can be unit tested normally. + */ +object PythonLexerUtils { + + def isIdentChar(c: Char): Boolean = c.isLetterOrDigit || c == '_' + + /** Characters that would make an Encodable-expression splice ambiguous/invalid if adjacent. */ + def isBadNeighbor(c: Char): Boolean = c == '\'' || c == '"' || isIdentChar(c) + + /** Returns the substring after the last newline (used to reason about the "current line" context). */ + def lineTail(s: String): String = { + val lastNewlineIndex = s.lastIndexOf('\n') + if (lastNewlineIndex >= 0) s.substring(lastNewlineIndex + 1) else s + } + + /** + * Detect whether the provided line tail contains an unclosed single or double quote. + * + * This is not a full Python parser; it is a small state machine tracking quote mode and escapes. + */ + def hasUnclosedQuote(lineText: String): Boolean = { + var inSingleQuotes = false + var inDoubleQuotes = false + var escaped = false + + var i = 0 + while (i < lineText.length) { + val ch = lineText.charAt(i) + if (escaped) escaped = false + else if (ch == '\\') escaped = true + else if (!inDoubleQuotes && ch == '\'') inSingleQuotes = !inSingleQuotes + else if (!inSingleQuotes && ch == '"') inDoubleQuotes = !inDoubleQuotes + i += 1 + } + inSingleQuotes || inDoubleQuotes + } + + /** + * Detect whether the provided line tail contains a `#` that is outside of any quote context. + * + * If true, any Encodable-expression splice after that point would be inside a Python comment. + */ + def hasCommentOutsideQuotes(lineText: String): Boolean = { + var inSingleQuotes = false + var inDoubleQuotes = false + var escaped = false + + var i = 0 + while (i < lineText.length) { + val ch = lineText.charAt(i) + if (escaped) escaped = false + else if (ch == '\\') escaped = true + else if (!inDoubleQuotes && ch == '\'') inSingleQuotes = !inSingleQuotes + else if (!inSingleQuotes && ch == '"') inDoubleQuotes = !inDoubleQuotes + else if (!inSingleQuotes && !inDoubleQuotes && ch == '#') return true + i += 1 + } + false + } +} diff --git a/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala new file mode 100644 index 00000000000..dc9e977d329 --- /dev/null +++ b/common/pybuilder/src/main/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilder.scala @@ -0,0 +1,481 @@ +/* + * 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.pybuilder + +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.RenderMode.{Encode, Plain} + +import java.nio.charset.StandardCharsets +import java.util.Base64 +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +/** + * Convenience type aliases for strings passed into the [[PythonTemplateBuilder]] interpolator. + * + * Design intent: + * - Some strings are “UI-provided” and must be rendered as a Python expression that decodes base64 at runtime. + * - Other strings are regular Python source fragments and should be spliced in as-is. + * + * The macro distinguishes Encodable strings via a TYPE_USE annotation (`String @EncodableStringAnnotation`). + */ +object PyStringTypes { + + /** + * Treated as an Encodable string by the macro via a TYPE_USE annotation. + * + * Example: + * {{{ + * import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableStringType + * import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + * + * val label: EncodableStringType = "Hello" + * val code = pyb"print($label)" + * }}} + */ + type EncodableString = String @EncodableStringAnnotation + + /** + * Normal python string (macro defaults to [[PythonLiteral]] when no [[EncodableStringAnnotation]] is present). + * + * This alias exists mostly for readability and symmetry with [[EncodableStringFactory]]. + */ + type PythonLiteral = String + + /** + * Helper “constructor” and constants for [[EncodableString]]. + * + * Note: the object and members are annotated so downstream type inference tends + * to keep the TYPE_USE annotation attached in common scenarios. + */ + @EncodableStringAnnotation + object EncodableStringFactory { + + /** Wrap a raw Scala string as an Encodable-marked string. */ + @EncodableStringAnnotation + def apply(s: String): EncodableString = s + + /** Empty Encodable string (still Encodable-marked). */ + @EncodableStringAnnotation + val empty: EncodableString = "" + } + + /** + * Helper “constructor” and constants for [[PythonLiteral]]. + * + * This does not apply any Encodable semantics. It is regular Scala `String` usage. + */ + object PyLiteralFactory { + + /** Identity wrapper, used as a readability hint at call sites. */ + def apply(s: String): PythonLiteral = s + + /** Empty python string. */ + val empty: PythonLiteral = "" + } +} + +/** + * =PythonTemplateBuilder: ergonomic Python codegen via `pyb"..."`= + * + * This module provides a tiny DSL for assembling Python source code from Scala while preserving two competing goals: + * (1) developers want to write templates that look like normal Python, and (2) user-provided text must not be injected + * into the emitted Python as raw literals that can break syntax or create ambiguous token boundaries. + * + * The core idea is that every value spliced into a `pyb"..."` template is first classified into one of two buckets: + * + * - '''Python literals''' (ordinary Scala strings or already-safe fragments) are inserted as-is. + * - '''Encodable strings''' (typically UI-provided text) are base64-encoded at build time and rendered as a *Python + * expression* that decodes at runtime, rather than being embedded as a Python string literal. + * + * This classification is driven by a TYPE_USE annotation: `String @EncodableStringAnnotation`. The annotation is defined + * with a runtime retention and is allowed on fields, parameters, local variables, and type uses, so it survives many + * common Scala typing patterns (e.g., inferred vals, constructor params, or aliases). Users normally do not construct the + * annotation directly; instead, they use helper type aliases/factories in `PyStringTypes` for readability. + * + * ==Render modes== + * + * A `PythonTemplateBuilder` can be rendered in two modes: + * + * - `plain`: emit everything as raw text (useful for debugging or when you know all content is safe). + * - `encode`: emit encodable chunks as Python decode expressions (the default `toString` behavior). + * + * Internally this is represented as a small sealed trait enum (`RenderMode.Plain` / `RenderMode.Encode`) rather than an + * integer flag, to keep call sites self-documenting and avoid “magic numbers”. + * + * ==Chunk model (immutable, composable)== + * + * A builder is an immutable list of chunks: + * + * - `Text(value)` for literal template parts + * - `Value(renderer)` for interpolated arguments that know how to render in each mode + * + * Two concrete renderers are provided: + * + * - `EncodableStringRenderer`: pre-encodes `stringValue` as base64 (UTF-8) once, and in `Encode` mode produces a Python + * expression like `self.decode_python_template('')` given by [[wrapWithPythonDecoderExpr]]. + * - `PyLiteralStringRenderer`: always emits the raw string value unchanged. + * + * Builders can be concatenated with `+` (builder + builder), which merges adjacent `Text` chunks for compactness. + * Direct concatenation with a plain `String` is intentionally unsupported to prevent bypassing the macro’s safety checks. + * + * ==How the `pyb"..."` macro works== + * + * The `pyb` interpolator is implemented as a Scala macro. At compile time it receives: + * + * - the literal parts from the `StringContext` (the “gaps” around `$args`) + * - the argument trees corresponding to each `$arg` + * + * The macro’s pipeline is: + * + * 1. '''Extract literal parts''' from the `StringContext` AST and ensure they are *string literals*. If any part is not + * a literal, compilation aborts. This prevents “template text” from being computed dynamically where correctness and + * boundary analysis would become unreliable. + * + * 2. '''Classify direct encodable arguments''' using `EncodableInspector`: + * it inspects both the argument symbol and the argument type to determine whether the encodable annotation is present. + * This includes a small “accessor hop” so that annotations placed on fields/constructor params are still visible when + * call sites reference getters. + * + * 3. '''Compile-time boundary validation for direct encodables''': + * if an argument is directly encodable (and not a nested builder), `BoundaryValidator.validateCompileTime` is run on + * its surrounding literal context. The validator performs quick lexical checks on the current line: + * + * - the splice must not occur inside an unclosed single/double-quoted string + * - the splice must not occur after a `#` comment marker + * - the splice must not be immediately adjacent to identifier characters or quote characters on either side + * + * These restrictions exist because an Encodable string renders as a Python *expression*, not a Python string literal. + * Putting an expression inside quotes, inside a comment, or glued to an identifier would either be invalid Python or + * silently change tokenization in surprising ways. + * + * 4. '''Lower each argument into a builder''': + * every `$arg` becomes a `PythonTemplateBuilder`. + * + * - If the argument is already a `PythonTemplateBuilder`, it is used directly. + * - Otherwise, it is wrapped into a `StringRenderer` (`EncodableStringRenderer` or `PyLiteralStringRenderer`) and + * turned into a minimal builder containing a single `Value(...)` chunk. + * + * Each argument is evaluated once and stored in a fresh local `val __pyb_argN` so that expensive expressions or + * side-effects are not duplicated by expansion. + * + * 5. '''Runtime safety for nested builders''': + * for arguments that are themselves `PythonTemplateBuilder`s, the macro cannot always know at compile time whether they + * contain Encodable chunks (they may be computed, returned, or composed elsewhere). For these nested builders, the macro + * conditionally emits runtime guards *only when the surrounding context is unsafe* (inside quotes, after comments, or + * adjacent to “bad neighbor” characters). The guard pattern is: + * + * {{{ + * if (__pyb_argN.containsEncodableString) throw new IllegalArgumentException("...") + * }}} + * + * This preserves the ergonomics of composing builders while keeping the same safety contract as direct splices. + * + * 6. '''Assemble the final builder''': + * the macro concatenates `text0 + arg0 + text1 + arg1 + ... + textN` into one `PythonTemplateBuilder`. + * + * ==Lexical checks (best-effort, intentionally small)== + * + * The boundary rules rely on `PythonLexerUtils`, a tiny state machine that scans only the “current line tail” to decide + * whether quotes are unbalanced and whether a `#` begins a comment outside quotes. This is not a full Python parser. + * It is deliberately lightweight so the macro stays fast and so the helpers can be unit-tested independently. + * + * ==Extensibility notes== + * + * The design keeps all rendering behavior behind `StringRenderer`, and keeps boundary policy in `BoundaryValidator`. + * If new encoding schemes, alternate runtime decode helpers, or additional safety rules are needed, they can be introduced + * without rewriting the template-building API. In particular, swapping `wrapWithPythonDecoderExpr` or adding new renderers + * is a contained change: the macro only needs to decide *which renderer* to use, not *how it renders*. + */ +object PythonTemplateBuilder { + + // ===== render mode enum (no Ints) ===== + def wrapWithPythonDecoderExpr(text: String): String = + s"self.decode_python_template('$text')" + + sealed trait RenderMode extends Product with Serializable + object RenderMode { + case object Plain extends RenderMode + case object Encode extends RenderMode + } + + // ===== wrappers ===== + + /** + * Base abstraction for values that can be spliced into a [[PythonTemplateBuilder]]. + * + * A [[StringRenderer]] knows how to render itself depending on `mode`. + */ + sealed trait StringRenderer extends Product with Serializable { + def stringValue: String + def render(mode: RenderMode): String + } + + /** + * Encodable string: encoded-mode wraps with [[wrapWithPythonDecoderExpr]], + * plain-mode is raw `stringValue`. + */ + final case class EncodableStringRenderer(stringValue: String) extends StringRenderer { + private val encodedB64: String = + Base64.getEncoder.encodeToString(stringValue.getBytes(StandardCharsets.UTF_8)) + + override def render(mode: RenderMode): String = + if (mode == Encode) wrapWithPythonDecoderExpr(encodedB64) else stringValue + } + + /** + * Python literal string: always raw `stringValue` regardless of mode. + */ + final case class PyLiteralStringRenderer(stringValue: String) extends StringRenderer { + override def render(mode: RenderMode): String = stringValue + } + + // ===== internal chunk model ===== + + private[pybuilder] sealed trait Chunk extends Product with Serializable + private[pybuilder] final case class Text(value: String) extends Chunk + private[pybuilder] final case class Value(value: StringRenderer) extends Chunk + + /** + * Build a [[PythonTemplateBuilder]] from literal parts and already-wrapped args. + * + * @param literalParts raw StringContext parts (length = args + 1) + * @param pyArgs args wrapped as [[StringRenderer]] + */ + private[amber] def fromInterpolated(literalParts: List[String], pyArgs: List[StringRenderer]): PythonTemplateBuilder = { + require( + literalParts.length == pyArgs.length + 1, + s"pyb interpolator mismatch: parts=${literalParts.length}, args=${pyArgs.length}" + ) + + val chunkBuilder = List.newBuilder[Chunk] + chunkBuilder += Text(literalParts.head) + + var argIndex = 0 + while (argIndex < pyArgs.length) { + chunkBuilder += Value(pyArgs(argIndex)) + chunkBuilder += Text(literalParts(argIndex + 1)) + argIndex += 1 + } + + new PythonTemplateBuilder(compact(chunkBuilder.result())) + } + + /** Merge adjacent text chunks. */ + private def compact(chunksToCompact: List[Chunk]): List[Chunk] = + chunksToCompact.foldRight(List.empty[Chunk]) { + case (Text(leftText), Text(rightText) :: remaining) => + Text(leftText + rightText) :: remaining + case (chunk, compactedTail) => + chunk :: compactedTail + } + + /** Concatenate chunk lists, merging boundary text chunks when possible. */ + private def concatChunks(leftChunks: List[Chunk], rightChunks: List[Chunk]): List[Chunk] = + (leftChunks, rightChunks) match { + case (Nil, _) => rightChunks + case (_, Nil) => leftChunks + case _ => + (leftChunks.last, rightChunks.head) match { + case (Text(leftText), Text(rightText)) => + compact(leftChunks.dropRight(1) ::: Text(leftText + rightText) :: rightChunks.tail) + case _ => + leftChunks ::: rightChunks + } + } + + // ===== custom interpolator ===== + + /** Adds the `pyb"..."` string interpolator. */ + implicit final class PythonTemplateBuilderStringContext(private val stringContext: StringContext) extends AnyVal { + def pyb(argValues: Any*): PythonTemplateBuilder = macro Macros.pybImpl + } + + object Macros { + + /** Macro entry point for `pyb"..."`. */ + def pybImpl(macroCtx: blackbox.Context)( + argValues: macroCtx.Expr[Any]* + ): macroCtx.Expr[PythonTemplateBuilder] = { + import macroCtx.universe._ + + // Stable, fully-qualified references as Trees/TypeTrees (NOT Strings) + val PTBTerm: Tree = + q"_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder" + val PTBType: Tree = + tq"_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder" + val StringRendererTpt: Tree = + tq"_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder.StringRenderer" + + val inspector = new EncodableInspector[macroCtx.type](macroCtx) + val validator = new BoundaryValidator[macroCtx.type](macroCtx) + + // --- extract literal parts from StringContext --- + val literalPartTrees: List[Tree] = macroCtx.prefix.tree match { + case Apply(_, List(Apply(_, rawPartTrees))) => rawPartTrees + case prefixTree => + macroCtx.abort( + macroCtx.enclosingPosition, + s"pyb interpolator: cannot extract StringContext parts from: ${showRaw(prefixTree)}" + ) + } + + // Ensure parts are string literals. + literalPartTrees.foreach { + case Literal(Constant(_: String)) => // ok + case nonLiteral => + macroCtx.abort( + macroCtx.enclosingPosition, + s"pyb interpolator requires literal parts; got: ${showRaw(nonLiteral)}" + ) + } + + val literalPartStrings: List[String] = + literalPartTrees.map { case Literal(Constant(s: String)) => s } + + // --- compile-time boundary checks for *direct* Encodable args --- + argValues.toList.zipWithIndex.foreach { + case (argExpr, argIndex) if inspector.isDirectEncodableStringArg(argExpr) => + val leftPart = literalPartStrings(argIndex) + val rightPart = literalPartStrings(argIndex + 1) + val prefixSource = literalPartStrings.take(argIndex + 1).mkString("") + val errorPos = + if (argExpr.tree.pos != NoPosition) argExpr.tree.pos else macroCtx.enclosingPosition + + validator.validateCompileTime( + validator.CompileTimeContext(leftPart, rightPart, prefixSource, argIndex, errorPos) + ) + + case _ => // no-op + } + + // --- builders for literal parts and args --- + val emptyRenderArgs = + q"_root_.scala.List.empty[$StringRendererTpt]" + + def textBuilder(partTree: Tree): Tree = + q"$PTBTerm.fromInterpolated(_root_.scala.List($partTree), $emptyRenderArgs)" + + val emptyStrLit: Tree = Literal(Constant("")) + + def valueBuilder(argExpr: macroCtx.Expr[Any]): Tree = { + val wrapped = inspector.wrapArg(argExpr) + q"$PTBTerm.fromInterpolated(_root_.scala.List($emptyStrLit, $emptyStrLit), _root_.scala.List($wrapped))" + } + + val pythonTemplateBuilderTpe = + typeOf[_root_.org.apache.texera.amber.pybuilder.PythonTemplateBuilder] + + def argAsBuilder(argExpr: macroCtx.Expr[Any]): Tree = { + val argTree = argExpr.tree + val argType = argTree.tpe + if (argType != null && (argType.dealias.widen <:< pythonTemplateBuilderTpe)) { + q"$argTree.asInstanceOf[$PTBType]" + } else { + valueBuilder(argExpr) + } + } + + // Evaluate each arg once. + val evaluatedArgBuilders: List[Tree] = + argValues.toList.zipWithIndex.map { + case (argExpr, i) => + val argValName = TermName(s"__pyb_arg$i") + q"val $argValName: $PTBType = ${argAsBuilder(argExpr)}" + } + + // Runtime boundary checks for nested PythonTemplateBuilders that *may* contain Encodable chunks. + val nestedBuilderBoundaryChecks: List[Tree] = + argValues.toList.zipWithIndex.flatMap { + case (argExpr, argIndex) if inspector.isPythonTemplateBuilderArg(argExpr) => + val leftPart = literalPartStrings(argIndex) + val rightPart = literalPartStrings(argIndex + 1) + val prefixSource = literalPartStrings.take(argIndex + 1).mkString("") + + val argIdent = Ident(TermName(s"__pyb_arg$argIndex")) + validator.runtimeChecksForNestedBuilder( + validator.RuntimeContext(leftPart, rightPart, prefixSource, argIndex), + argIdent + ) + + case _ => Nil + } + + // Concatenate: text0 + arg0 + text1 + arg1 + ... + textN + val renderTree: Tree = { + val baseTree = textBuilder(literalPartTrees.head) + argValues.toList.zipWithIndex.foldLeft(baseTree) { + case (acc, (_, i)) => + val argIdent = Ident(TermName(s"__pyb_arg$i")) + val nextText = textBuilder(literalPartTrees(i + 1)) + q"$acc + $argIdent + $nextText" + } + } + + val finalExpr: Tree = + q""" + { + ..$evaluatedArgBuilders + ..$nestedBuilderBoundaryChecks + $renderTree + } + """ + + macroCtx.Expr[PythonTemplateBuilder](finalExpr) + } + } +} + +/** + * An immutable builder for Python source produced via `pyb"..."` interpolation. + */ +final class PythonTemplateBuilder private[pybuilder] (private val chunks: List[PythonTemplateBuilder.Chunk]) + extends Serializable { + import PythonTemplateBuilder._ + + def +(that: PythonTemplateBuilder): PythonTemplateBuilder = + new PythonTemplateBuilder(concatChunks(this.chunks, that.chunks)) + + def +(that: String): PythonTemplateBuilder = + throw new UnsupportedOperationException(s"Direct String concatenation is not supported $that") + + def plain: String = render(Plain) + + def encode: String = render(Encode) + + override def toString: String = encode + + def containsEncodableString: Boolean = + chunks.exists { + case Value(_: EncodableStringRenderer) => true + case _ => false + } + + private def render(renderMode: RenderMode): String = { + val out = new java.lang.StringBuilder + chunks.foreach { + case Text(text) => out.append(text) + case Value(renderer) => out.append(renderer.render(renderMode)) + } + out.toString + .stripMargin + .replace("\r\n", "\n") + .replace("\r", "\n") + } +} diff --git a/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonLexerUtilsSpec.scala b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonLexerUtilsSpec.scala new file mode 100644 index 00000000000..ea473969e7e --- /dev/null +++ b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonLexerUtilsSpec.scala @@ -0,0 +1,167 @@ +/* + * 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.pybuilder + +import org.scalatest.funsuite.AnyFunSuite + +class PythonLexerUtilsSpec extends AnyFunSuite { + + // -------- isIdentChar -------- + + test("isIdentChar: lowercase letter is identifier char") { + assert(PythonLexerUtils.isIdentChar('a')) + } + + test("isIdentChar: uppercase letter is identifier char") { + assert(PythonLexerUtils.isIdentChar('Z')) + } + + test("isIdentChar: digit is identifier char") { + assert(PythonLexerUtils.isIdentChar('5')) + } + + test("isIdentChar: underscore is identifier char") { + assert(PythonLexerUtils.isIdentChar('_')) + } + + test("isIdentChar: dash is not identifier char") { + assert(!PythonLexerUtils.isIdentChar('-')) + } + + test("isIdentChar: space is not identifier char") { + assert(!PythonLexerUtils.isIdentChar(' ')) + } + + test("isIdentChar: hash is not identifier char") { + assert(!PythonLexerUtils.isIdentChar('#')) + } + + // -------- isBadNeighbor -------- + + test("isBadNeighbor: single quote is bad neighbor") { + assert(PythonLexerUtils.isBadNeighbor('\'')) + } + + test("isBadNeighbor: double quote is bad neighbor") { + assert(PythonLexerUtils.isBadNeighbor('"')) + } + + test("isBadNeighbor: identifier chars are bad neighbors") { + assert(PythonLexerUtils.isBadNeighbor('a')) + assert(PythonLexerUtils.isBadNeighbor('Z')) + assert(PythonLexerUtils.isBadNeighbor('0')) + assert(PythonLexerUtils.isBadNeighbor('_')) + } + + test("isBadNeighbor: whitespace is not bad neighbor") { + assert(!PythonLexerUtils.isBadNeighbor(' ')) + } + + test("isBadNeighbor: punctuation like comma is not bad neighbor") { + assert(!PythonLexerUtils.isBadNeighbor(',')) + } + + // -------- lineTail -------- + + test("lineTail: string without newline returns full string") { + val text = "no-newline" + assert(PythonLexerUtils.lineTail(text) == text) + } + + test("lineTail: returns text after single newline") { + val text = "first\nsecond" + assert(PythonLexerUtils.lineTail(text) == "second") + } + + test("lineTail: returns text after last newline") { + val text = "a\nb\nc\nlast-line" + assert(PythonLexerUtils.lineTail(text) == "last-line") + } + + test("lineTail: works with trailing newline (returns empty)") { + val text = "first\nsecond\n" + assert(PythonLexerUtils.lineTail(text) == "") + } + + // -------- hasUnclosedQuote -------- + + test("hasUnclosedQuote: empty string has no unclosed quote") { + assert(!PythonLexerUtils.hasUnclosedQuote("")) + } + + test("hasUnclosedQuote: balanced single quotes returns false") { + assert(!PythonLexerUtils.hasUnclosedQuote("'a'")) + } + + test("hasUnclosedQuote: balanced double quotes returns false") { + assert(!PythonLexerUtils.hasUnclosedQuote("\"a\"")) + } + + test("hasUnclosedQuote: unclosed single quote returns true") { + assert(PythonLexerUtils.hasUnclosedQuote("'unclosed")) + } + + test("hasUnclosedQuote: unclosed double quote returns true") { + assert(PythonLexerUtils.hasUnclosedQuote("\"unclosed")) + } + + test("hasUnclosedQuote: escaped single quote inside single quotes does not break balance") { + val text = "'it\\'s ok'" + assert(!PythonLexerUtils.hasUnclosedQuote(text)) + } + + test("hasUnclosedQuote: escaped double quote inside double quotes does not break balance") { + val text = "\"he said \\\"hi\\\"\"" + assert(!PythonLexerUtils.hasUnclosedQuote(text)) + } + + test("hasUnclosedQuote: mixed quotes with proper closing returns false") { + val text = "'a' + \"b\"" + assert(!PythonLexerUtils.hasUnclosedQuote(text)) + } + + // -------- hasCommentOutsideQuotes -------- + + test("hasCommentOutsideQuotes: no hash means no comment") { + assert(!PythonLexerUtils.hasCommentOutsideQuotes("print(1)")) + } + + test("hasCommentOutsideQuotes: hash outside quotes is a comment") { + assert(PythonLexerUtils.hasCommentOutsideQuotes("x = 1 # comment")) + } + + test("hasCommentOutsideQuotes: hash inside single quotes is not a comment") { + assert(!PythonLexerUtils.hasCommentOutsideQuotes("print('# not comment')")) + } + + test("hasCommentOutsideQuotes: hash inside double quotes is not a comment") { + assert(!PythonLexerUtils.hasCommentOutsideQuotes("print(\"# not comment\")")) + } + + test("hasCommentOutsideQuotes: escaped quotes preserve quote state correctly") { + val line = "print(\"\\\"# still in string\\\"\") # comment here" + assert(PythonLexerUtils.hasCommentOutsideQuotes(line)) + } + + test("hasCommentOutsideQuotes: multiple hashes only first outside quotes matters") { + val line = "print('# in string') # real comment # more" + assert(PythonLexerUtils.hasCommentOutsideQuotes(line)) + } +} diff --git a/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala new file mode 100644 index 00000000000..d2e423f810a --- /dev/null +++ b/common/pybuilder/src/test/scala/org/apache/texera/amber/pybuilder/PythonTemplateBuilderSpec.scala @@ -0,0 +1,598 @@ +/* + * 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.pybuilder + +import org.apache.texera.amber.pybuilder.PyStringTypes.{EncodableString, PythonLiteral} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.{EncodableStringRenderer, PyLiteralStringRenderer, PythonTemplateBuilderStringContext} +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.charset.StandardCharsets +import java.util.Base64 +import scala.annotation.meta.field +import scala.reflect.runtime.currentMirror +import scala.tools.reflect.ToolBox + +class PythonTemplateBuilderSpec extends AnyFunSuite { + + // ---------- Helpers ---------- + private def base64Of(text: String): String = + Base64.getEncoder.encodeToString(text.getBytes(StandardCharsets.UTF_8)) + + private def decodeExpr(text: String): String = + PythonTemplateBuilder.wrapWithPythonDecoderExpr(base64Of(text)) + // Toolbox helpers: used to assert runtime exceptions without checking error strings. + private lazy val tb: ToolBox[scala.reflect.runtime.universe.type] = currentMirror.mkToolBox() + + private def inPybuilderPkg(code: String): String = + s"""package org.apache.texera.amber.pybuilder { + | + |$code + | + |}""".stripMargin + + private def assertToolboxDoesNotCompile(code: String): Unit = { + intercept[Throwable] { + // compile only (don’t run); macro expansion happens during compilation + tb.compile(tb.parse(inPybuilderPkg(code))) + } + () + } + // Unicode escapes in *generated* Scala source: must be written as "\\uXXXX" in this test file. + private def scalaUnicodeEscape(ch: Char): String = + f"\\\\u${ch.toInt}%04X" + + // ======================================================================== + // Rendering basics (plain vs encoded) + // ======================================================================== + + test("plain renders empty text") { + val builder = pyb"" + assert(builder.plain == "") + } + + test("plain renders literal text") { + val builder = pyb"hello" + assert(builder.plain == "hello") + } + + test("encoded renders literal text (no UI args) same as plain") { + val builder = pyb"hello" + assert(builder.encode == "hello") + } + + test("toString defaults to encoded") { + val builder = pyb"hello" + assert(builder.toString == builder.encode) + } + + test("StringPyMk renders raw in both modes") { + val pyFragment = PyLiteralStringRenderer("print('x')") + assert(pyFragment.render(PythonTemplateBuilder.RenderMode.Plain) == "print('x')") + assert(pyFragment.render(PythonTemplateBuilder.RenderMode.Encode) == "print('x')") + } + + test("EncodableString renders raw in plain mode") { + val uiText = EncodableStringRenderer("abc") + assert(uiText.render(PythonTemplateBuilder.RenderMode.Plain) == "abc") + } + + test("EncodableString renders B64.decode('') in encoded mode") { + val rawText = "abc" + val uiText = EncodableStringRenderer(rawText) + assert(uiText.render(PythonTemplateBuilder.RenderMode.Encode) == decodeExpr(rawText)) + } + + test("EncodableString base64 uses UTF-8 and handles unicode") { + val rawText = "你好 👋" + val uiText = EncodableStringRenderer(rawText) + assert(uiText.render(PythonTemplateBuilder.RenderMode.Encode) == decodeExpr(rawText)) + assert(uiText.render(PythonTemplateBuilder.RenderMode.Plain) == rawText) + } + + test("pyb interpolator defaults to StringPyMk for normal values (toString)") { + val value = 42 + val builder = pyb"val=$value" + assert(builder.plain == "val=42") + assert(builder.encode == "val=42") + } + + test("pyb supports multiple args") { + val firstValue = 1 + val secondValue = "two" + val thirdValue = 3.0 + val builder = pyb"a=$firstValue b=$secondValue c=$thirdValue" + assert(builder.plain == "a=1 b=two c=3.0") + } + + test("passing a PyString (EncodableString) is preserved (no re-wrapping)") { + val rawText = "ui" + val uiPyString: PythonTemplateBuilder.StringRenderer = EncodableStringRenderer(rawText) + val builder = pyb"$uiPyString" + assert(builder.plain == rawText) + assert(builder.encode == decodeExpr(rawText)) + } + + test("passing a PyString (StringPyMk) is preserved") { + val rawPy: PythonTemplateBuilder.StringRenderer = PyLiteralStringRenderer("x + 1") + val builder = pyb"$rawPy" + assert(builder.plain == "x + 1") + assert(builder.encode == "x + 1") + } + + // ======================================================================== + // Whitespace / multiline / normalization + // ======================================================================== + + test("stripMargin is applied on render() output") { + val builder = + pyb"""|line1 + |line2""" + assert(builder.plain == "line1\nline2") + } + + test("stripMargin works with interpolation too") { + val value = 7 + val builder = + pyb"""|line1 $value + |line2""" + assert(builder.plain == "line1 7\nline2") + } + + // ======================================================================== + // Concatenation + // ======================================================================== + + test("operator + concatenates builders") { + val left = pyb"hello " + val right = pyb"world" + assert((left + right).plain == "hello world") + } + + test("operator + preserves encoded behavior when mixing UI and raw") { + val uiText = EncodableStringRenderer("X") + val prefix = pyb"pre:" + val middle = pyb"$uiText" + val suffix = pyb":post" + val combined = prefix + middle + suffix + assert(combined.plain == "pre:X:post") + assert(combined.encode == s"pre:${decodeExpr("X")}:post") + } + + test("repeated concatenation still renders correctly") { + val combined = pyb"a" + pyb"b" + pyb"c" + assert(combined.plain == "abc") + assert(combined.encode == "abc") + } + + test("empty builder renders empty") { + val builder = pyb"" + assert(builder.plain.isEmpty) + assert(builder.encode.isEmpty) + } + + // ======================================================================== + // Annotation / TYPE_USE behavior + // ======================================================================== + + test("TYPE_USE alias EncodableString triggers UI encoding") { + val uiText: EncodableString = "hello" + val builder = pyb"$uiText" + assert(builder.plain == "hello") + assert(builder.encode == decodeExpr("hello")) + } + + test("EncodableString helper apply triggers UI encoding") { + val uiText: EncodableString = PyStringTypes.EncodableStringFactory("hey") + val builder = pyb"$uiText" + assert(builder.encode == decodeExpr("hey")) + } + + test("TYPE_USE annotation on val type triggers UI encoding") { + val uiText: String @EncodableStringAnnotation = "typeuse" + val builder = pyb"$uiText" + assert(builder.encode == decodeExpr("typeuse")) + } + + test("@StringUI parameter triggers UI encoding") { + def build(@EncodableStringAnnotation uiText: String): PythonTemplateBuilder = pyb"$uiText" + val builder = build("param") + assert(builder.encode == decodeExpr("param")) + } + + test("@StringUI local val triggers UI encoding") { + def build(): PythonTemplateBuilder = { + @EncodableStringAnnotation val uiText: String = "local" + pyb"$uiText" + } + val builder = build() + assert(builder.encode == decodeExpr("local")) + } + + test("@StringUI local val triggers UI encoding even when type is inferred") { + def build(): PythonTemplateBuilder = { + @EncodableStringAnnotation val uiText = "local-inferred" + pyb"$uiText" + } + val builder = build() + assert(builder.encode == decodeExpr("local-inferred")) + } + + test("@StringUI lambda parameter triggers UI encoding") { + val uiToBuilder: (String @EncodableStringAnnotation) => PythonTemplateBuilder = uiText => pyb"$uiText" + val builder = uiToBuilder("lambda") + assert(builder.encode == decodeExpr("lambda")) + } + + test("@StringUI lambda param + map + mkString triggers UI encoding per element") { + val rawItems = List("a", "b", "c") + val joinedEncoded = + rawItems.map((uiItem: String @EncodableStringAnnotation) => pyb"$uiItem").mkString("[", ", ", "]") + assert(joinedEncoded == s"[${rawItems.map(decodeExpr).mkString(", ")}]") + } + + test("List[String @StringUI] element access preserves UI encoding") { + val uiItems: List[String @EncodableStringAnnotation] = List("first", "second") + val first = uiItems.head + val builder = pyb"$first" + assert(builder.encode == decodeExpr("first")) + } + + test("Erasing List[String @StringUI] to List[String] drops UI encoding") { + val uiItems: List[String @EncodableStringAnnotation] = List("erased") + val erased: List[String] = uiItems.map((uiItem: String @EncodableStringAnnotation) => (uiItem: String)) + val builder = pyb"${erased.head}" + assert(builder.encode == "erased") + } + + test("@(StringUI @field) on case class field triggers UI encoding via accessor/field") { + final case class WithFieldAnnotation(@(EncodableStringAnnotation @field) uiText: String) + val value = WithFieldAnnotation("field") + val builder = pyb"${value.uiText}" + assert(builder.encode == decodeExpr("field")) + } + + test("@StringUI on case class param without @field does not trigger UI encoding via accessor") { + final case class WithoutFieldAnnotation(@EncodableStringAnnotation uiText: String) + val value = WithoutFieldAnnotation("param-only") + val builder = pyb"${value.uiText}" + assert(builder.encode == "param-only") + } + + test("unannotated String does not become UI (stays raw python)") { + val rawText: String = "raw" + val builder = pyb"$rawText" + assert(builder.encode == "raw") + } + + test("StringPy alias remains raw") { + val rawText: PythonLiteral = "raw2" + val builder = pyb"$rawText" + assert(builder.encode == "raw2") + } + + // ======================================================================== + // Compile-time checks (direct UI args) + // ======================================================================== + + test("UI with whitespace boundaries compiles") { + assertCompiles(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiWhitespaceBoundariesOk { val ui: EncodableString = "x"; val b = pyb"foo $ui bar" } + """) + } + + test("UI next to comma is allowed (common in function args)") { + assertCompiles(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiCommaOk { val ui: EncodableString = "x"; val b = pyb"f($ui, 1)" } + """) + } + + test("UI next to parentheses is allowed") { + assertCompiles(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiParensOk { val ui: EncodableString = "x"; val b = pyb"($ui)" } + """) + } + + test("hash inside quotes does not count as a comment marker (UI allowed afterwards)") { + assertCompiles(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object HashInQuotesOk { val ui: EncodableString = "x"; val b = pyb"print('#') $ui" } + """) + } + + test("UI glued to identifier on the left does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiGluedLeftBad { val ui: EncodableString = "x"; val b = pyb"foo$ui" } + """) + } + + test("UI glued to identifier on the right does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiGluedRightBad { val ui: EncodableString = "x"; val b = pyb"${ui}bar" } + """) + } + + test("UI glued to a quote on the right does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiGluedQuoteBad { val ui: EncodableString = "x"; val b = pyb"${ui}'" } + """) + } + + test("UI placed inside a quoted python string literal does not compile (single quotes)") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiInsideSingleQuotesBad { val ui: EncodableString = "x"; val b = pyb"print('${ui}')" } + """) + } + + test("UI placed inside a quoted python string literal does not compile (double quotes)") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiInsideDoubleQuotesBad { + val ui: EncodableString = "x" + val b = pyb"print(\\"${ui}\\")" + } + """) + } + + test("UI placed after a python comment marker on same line does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiAfterCommentBad { val ui: EncodableString = "x"; val b = pyb"foo # ${ui}" } + """) + } + + test("UI placed after a python comment marker on same line does not compile (no whitespace)") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PyStringTypes._ + object UiAfterCommentNoSpaceBad { val ui: EncodableString = "x"; val b = pyb"foo #${ui}" } + """) + } + + test("comment marker on previous line does not affect next line (lineTail behavior)") { + assertCompiles( + "import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._\n" + + "import org.apache.texera.amber.pybuilder.PyStringTypes._\n" + + "object CommentPrevLineOk {\n" + + " val ui: EncodableString = \"x\"\n" + + " val b = pyb\"\"\"|# comment\n" + + " |$ui\"\"\"\n" + + "}\n" + ) + } + + + test("PyString (EncodableString) glued to identifier on the left does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.EncodableString + object PyStringGluedLeftBad { val ui = EncodableString("x"); val b = pyb"foo${ui}" } + """) + } + + test("PyString (EncodableString) inside a quoted python string literal does not compile") { + assertDoesNotCompile(""" + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.EncodableString + object PyStringInsideQuotesBad { val ui = EncodableString("x"); val b = pyb"print('${ui}')" } + """) + } + + test("all isBadNeighbor characters reject direct UI adjacency at compile time (left + right)") { + val candidates = (33 to 126).map(_.toChar) // printable ASCII, avoids whitespace + val badChars = candidates.filter(PythonLexerUtils.isBadNeighbor) + + // This is intentionally exhaustive over the implementation-defined "bad neighbor" set. + // We assert only compile success/failure, not the specific error message. + badChars.zipWithIndex.foreach { case (ch, i) => + val esc = scalaUnicodeEscape(ch) + + val leftAdj = + s""" + |import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + |import org.apache.texera.amber.pybuilder.PyStringTypes._ + |object UiBadLeft_$i { + | val ui: EncodableString = "x" + | val b = pyb\"\"\"pre$esc${'$'}{ui}post\"\"\" + |} + |""".stripMargin + + val rightAdj = + s""" + |import org.apache.texera.amber.pybuilder.PythonTemplateBuilder._ + |import org.apache.texera.amber.pybuilder.PyStringTypes._ + |object UiBadRight_$i { + | val ui: EncodableString = "x" + | val b = pyb\"\"\"pre${'$'}{ui}$esc post\"\"\" + |} + |""".stripMargin + + assertToolboxDoesNotCompile(leftAdj) + assertToolboxDoesNotCompile(rightAdj) + } + } + + // ======================================================================== + // Interpolator semantics / evaluation + // ======================================================================== + + test("interpolated args are evaluated once and not re-evaluated on render") { + var evalCount = 0 + def nextValue(): String = { + evalCount += 1 + "v" + } + + val builder = pyb"${nextValue()}" + assert(evalCount == 1) + + builder.plain + assert(evalCount == 1) + + builder.encode + assert(evalCount == 1) + } + + // ======================================================================== + // Nested PythonTemplateBuilder behavior (mode propagation + runtime UI checks) + // ======================================================================== + + test("nested PythonTemplateBuilder with UI propagates mode (plain)") { + val uiText = EncodableStringRenderer("Z") + val inner = pyb"X=$uiText" + val outer = pyb"pre $inner post" + assert(outer.plain == "pre X=Z post") + } + + test("nested PythonTemplateBuilder with UI propagates mode (encoded)") { + val uiText = EncodableStringRenderer("Z") + val inner = pyb"X=$uiText" + val outer = pyb"pre $inner post" + assert(outer.encode == s"pre X=${decodeExpr("Z")} post") + } + + test("nested PythonTemplateBuilder without UI can appear inside python quotes (no runtime checks)") { + val inner = pyb"hello" + val outer = pyb"print('$inner')" + assert(outer.plain == "print('hello')") + assert(outer.encode == "print('hello')") + } + + test("containsUi detects UI chunks correctly") { + val rawBuilder = pyb"raw" + val uiBuilder = pyb"${EncodableStringRenderer("x")}" + val combined = rawBuilder + uiBuilder + assert(!rawBuilder.containsEncodableString) + assert(uiBuilder.containsEncodableString) + assert(combined.containsEncodableString) + } + + test("nested PythonTemplateBuilder containing UI inside single quotes throws at runtime") { + val inner = pyb"${EncodableStringRenderer("x")}" + intercept[IllegalArgumentException] { + pyb"print('$inner')" + } + } + + test("nested PythonTemplateBuilder containing UI inside double quotes throws at runtime") { + val inner = pyb"${EncodableStringRenderer("x")}" + intercept[IllegalArgumentException] { + pyb"""print("$inner")""" + } + } + + test("nested PythonTemplateBuilder containing UI after comment marker throws at runtime (with and without whitespace)") { + val inner = pyb"${EncodableStringRenderer("x")}" + intercept[IllegalArgumentException] { + pyb"foo # $inner" + } + intercept[IllegalArgumentException] { + pyb"foo #$inner" + } + } + + test("nested PythonTemplateBuilder containing UI glued to identifier/digit throws at runtime") { + val inner = pyb"${EncodableStringRenderer("x")}" + intercept[IllegalArgumentException] { pyb"foo$inner" } + intercept[IllegalArgumentException] { pyb"${inner}bar" } + intercept[IllegalArgumentException] { pyb"1$inner" } + intercept[IllegalArgumentException] { pyb"${inner}2" } + } + + test("runtime guard does NOT throw when nested builder has no UI, even in unsafe boundary contexts") { + val inner = pyb"hello" + val outer1 = pyb"foo$inner" + val outer2 = pyb"${inner}bar" + val outer3 = pyb"print('$inner')" + val outer4 = pyb"foo #$inner" + + assert(outer1.plain == "foohello") + assert(outer2.plain == "hellobar") + assert(outer3.plain == "print('hello')") + assert(outer4.plain == "foo #hello") + } + + test("nested PythonTemplateBuilder containing UI with safe whitespace boundaries is allowed") { + val inner = pyb"${EncodableStringRenderer("x")}" + val outer = pyb"foo $inner bar" + assert(outer.plain == "foo x bar") + assert(outer.encode == s"foo ${decodeExpr("x")} bar") + } + + test("nested PythonTemplateBuilder containing UI next to punctuation is allowed") { + val inner = pyb"${EncodableStringRenderer("x")}" + val outer = pyb"f($inner, 1)" + assert(outer.plain == "f(x, 1)") + assert(outer.encode == s"f(${decodeExpr("x")}, 1)") + } + + test("stripMargin works across nested builders") { + val inner = + pyb"""A + |B""" + val outer = + pyb"""|start + |$inner + |end""" + assert(outer.plain == "start\nA\nB\nend") + } + + test("""format(): EncodableString arg after closing quote is allowed""") { + val workflowParam = "wf" + val portParam = PythonTemplateBuilder.EncodableStringRenderer("P") + + val builder = pyb""""$workflowParam".format($portParam)""" + assert(builder.plain == "\"wf\".format(P)") + assert(builder.encode.contains("self.decode_python_template(")) + } + + test("format(): nested PythonTemplateBuilder containing UI is allowed (no runtime false positive)") { + val workflowParam = "wf" + val portParam = pyb"int (${PythonTemplateBuilder.EncodableStringRenderer("\\.")})," + + val builder = pyb""""$workflowParam".format($portParam)""" + assert(builder.plain.contains("format(int (\\.),")) + assert(builder.encode.contains("self.decode_python_template(")) + } + + test("still rejects nested UI builder inside Python quotes at runtime") { + val portParam = pyb"${PythonTemplateBuilder.EncodableStringRenderer("P")}" + + intercept[IllegalArgumentException] { + pyb"print('${portParam}')".plain + } + } +} diff --git a/common/workflow-operator/build.sbt b/common/workflow-operator/build.sbt index 9f7f5b22a47..6af8b3c6ae1 100644 --- a/common/workflow-operator/build.sbt +++ b/common/workflow-operator/build.sbt @@ -116,3 +116,5 @@ libraryDependencies ++= Seq( "org.apache.lucene" % "lucene-analyzers-common" % "8.11.4", "io.github.redouane59.twitter" % "twittered" % "2.21" ) + +libraryDependencies += "io.github.classgraph" % "classgraph" % "4.8.184" % Test diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceIrisLogisticRegressionOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceIrisLogisticRegressionOpDesc.scala index 81ae9e03df5..9a9ac563250 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceIrisLogisticRegressionOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceIrisLogisticRegressionOpDesc.scala @@ -21,21 +21,24 @@ package org.apache.texera.amber.operator.huggingFace import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} + class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "petalLengthCmAttribute", required = true) @JsonPropertyDescription("attribute in your dataset corresponding to PetalLengthCm") @AutofillAttributeName - var petalLengthCmAttribute: String = _ + var petalLengthCmAttribute: EncodableString = _ @JsonProperty(value = "petalWidthCmAttribute", required = true) @JsonPropertyDescription("attribute in your dataset corresponding to PetalWidthCm") @AutofillAttributeName - var petalWidthCmAttribute: String = _ + var petalWidthCmAttribute: EncodableString = _ @JsonProperty( value = "prediction class name", @@ -43,7 +46,7 @@ class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { defaultValue = "Species_prediction" ) @JsonPropertyDescription("output attribute name for the predicted class of species") - var predictionClassName: String = _ + var predictionClassName: EncodableString = _ @JsonProperty( value = "prediction probability name", @@ -53,7 +56,7 @@ class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription( "output attribute name for the prediction's probability of being a Iris-setosa" ) - var predictionProbabilityName: String = _ + var predictionProbabilityName: EncodableString = _ /** * Python code to apply a pre-trained liner regression model on the Iris dataset. @@ -62,7 +65,7 @@ class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { * @return a String representation of the executable Python source code. */ override def generatePythonCode(): String = { - s"""from pytexera import * + pyb"""from pytexera import * |import numpy as np |import torch |import torch.nn as nn @@ -86,8 +89,8 @@ class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { | def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: | training_features_means = [3.72666667, 1.17619048] | training_features_stds = [1.72528903, 0.73788937] - | length = tuple_["$petalLengthCmAttribute"] - | width = tuple_["$petalWidthCmAttribute"] + | length = tuple_[$petalLengthCmAttribute] + | width = tuple_[$petalWidthCmAttribute] | features = np.array([[length, width]]) | features = ((features - training_features_means) / training_features_stds) | features = torch.from_numpy(features).float() @@ -95,9 +98,9 @@ class HuggingFaceIrisLogisticRegressionOpDesc extends PythonOperatorDescriptor { | logits = self.model(features) | proba = torch.sigmoid(logits.squeeze()) | preds = (proba > 0.5).long() - | tuple_["$predictionProbabilityName"] = float(proba) - | tuple_["$predictionClassName"] = "Iris-setosa" if preds == 1 else "Not Iris-setosa" - | yield tuple_""".stripMargin + | tuple_[$predictionProbabilityName] = float(proba) + | tuple_[$predictionClassName] = "Iris-setosa" if preds == 1 else "Not Iris-setosa" + | yield tuple_""".encode } override def operatorInfo: OperatorInfo = diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDesc.scala index 551b25b4810..1d6cc7be9c5 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDesc.scala @@ -25,11 +25,13 @@ import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentit import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext class HuggingFaceSentimentAnalysisOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "attribute", required = true) @JsonPropertyDescription("column to perform sentiment analysis on") @AutofillAttributeName - var attribute: String = _ + var attribute: EncodableString = _ @JsonProperty( value = "Positive result attribute", @@ -37,7 +39,7 @@ class HuggingFaceSentimentAnalysisOpDesc extends PythonOperatorDescriptor { defaultValue = "huggingface_sentiment_positive" ) @JsonPropertyDescription("column name of the sentiment analysis result (positive)") - var resultAttributePositive: String = _ + var resultAttributePositive: EncodableString = _ @JsonProperty( value = "Neutral result attribute", @@ -45,7 +47,7 @@ class HuggingFaceSentimentAnalysisOpDesc extends PythonOperatorDescriptor { defaultValue = "huggingface_sentiment_neutral" ) @JsonPropertyDescription("column name of the sentiment analysis result (neutral)") - var resultAttributeNeutral: String = _ + var resultAttributeNeutral: EncodableString = _ @JsonProperty( value = "Negative result attribute", @@ -53,10 +55,10 @@ class HuggingFaceSentimentAnalysisOpDesc extends PythonOperatorDescriptor { defaultValue = "huggingface_sentiment_negative" ) @JsonPropertyDescription("column name of the sentiment analysis result (negative)") - var resultAttributeNegative: String = _ + var resultAttributeNegative: EncodableString = _ override def generatePythonCode(): String = { - s"""from pytexera import * + pyb"""from pytexera import * |from transformers import pipeline |from transformers import AutoModelForSequenceClassification |from transformers import TFAutoModelForSequenceClassification @@ -74,16 +76,16 @@ class HuggingFaceSentimentAnalysisOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: - | encoded_input = self.tokenizer(tuple_["$attribute"], return_tensors='pt') + | encoded_input = self.tokenizer(tuple_[$attribute], return_tensors='pt') | output = self.model(**encoded_input) | scores = softmax(output[0][0].detach().numpy()) | ranking = np.argsort(scores)[::-1] - | labels = {"positive": "$resultAttributePositive", "neutral": "$resultAttributeNeutral", "negative": "$resultAttributeNegative"} + | labels = {"positive": $resultAttributePositive, "neutral": $resultAttributeNeutral, "negative": $resultAttributeNegative} | for i in range(scores.shape[0]): | label = labels[self.config.id2label[ranking[i]]] | score = scores[ranking[i]] | tuple_[label] = np.round(float(score), 4) - | yield tuple_""".stripMargin + | yield tuple_""".encode } override def operatorInfo: OperatorInfo = diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSpamSMSDetectionOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSpamSMSDetectionOpDesc.scala index d5fdb24deba..cef9525570e 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSpamSMSDetectionOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSpamSMSDetectionOpDesc.scala @@ -25,11 +25,13 @@ import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentit import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext class HuggingFaceSpamSMSDetectionOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "attribute", required = true) @JsonPropertyDescription("column to perform spam detection on") @AutofillAttributeName - var attribute: String = _ + var attribute: EncodableString = _ @JsonProperty( value = "Spam result attribute", @@ -37,7 +39,7 @@ class HuggingFaceSpamSMSDetectionOpDesc extends PythonOperatorDescriptor { defaultValue = "is_spam" ) @JsonPropertyDescription("column name of whether spam or not") - var resultAttributeSpam: String = _ + var resultAttributeSpam: EncodableString = _ @JsonProperty( value = "Score result attribute", @@ -45,10 +47,10 @@ class HuggingFaceSpamSMSDetectionOpDesc extends PythonOperatorDescriptor { defaultValue = "score" ) @JsonPropertyDescription("column name of Probability for classification") - var resultAttributeProbability: String = _ + var resultAttributeProbability: EncodableString = _ override def generatePythonCode(): String = { - s"""from transformers import pipeline + pyb"""from transformers import pipeline |from pytexera import * | |class ProcessTupleOperator(UDFOperatorV2): @@ -58,10 +60,10 @@ class HuggingFaceSpamSMSDetectionOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: - | result = self.pipeline(tuple_["$attribute"])[0] - | tuple_["$resultAttributeSpam"] = (result["label"] == "LABEL_1") - | tuple_["$resultAttributeProbability"] = result["score"] - | yield tuple_""".stripMargin + | result = self.pipeline(tuple_[$attribute])[0] + | tuple_[$resultAttributeSpam] = (result["label"] == "LABEL_1") + | tuple_[$resultAttributeProbability] = result["score"] + | yield tuple_""".encode } override def operatorInfo: OperatorInfo = diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceTextSummarizationOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceTextSummarizationOpDesc.scala index 41d16c5c1b4..6b5c5449b42 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceTextSummarizationOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceTextSummarizationOpDesc.scala @@ -25,11 +25,13 @@ import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentit import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext class HuggingFaceTextSummarizationOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "attribute", required = true) @JsonPropertyDescription("attribute to perform text summarization on") @AutofillAttributeName - var attribute: String = _ + var attribute: EncodableString = _ @JsonProperty( value = "Result attribute name", @@ -37,10 +39,10 @@ class HuggingFaceTextSummarizationOpDesc extends PythonOperatorDescriptor { defaultValue = "summary" ) @JsonPropertyDescription("attribute name of the text summary result") - var resultAttribute: String = _ + var resultAttribute: EncodableString = _ override def generatePythonCode(): String = { - s""" + pyb""" |from transformers import BertTokenizerFast, EncoderDecoderModel |import torch |from pytexera import * @@ -55,7 +57,7 @@ class HuggingFaceTextSummarizationOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: - | text = tuple_["$attribute"] + | text = tuple_[$attribute] | | inputs = self.tokenizer([text], padding="max_length", truncation=True, max_length=512, return_tensors="pt") | input_ids = inputs.input_ids.to(self.device) @@ -63,8 +65,8 @@ class HuggingFaceTextSummarizationOpDesc extends PythonOperatorDescriptor { | | output = self.model.generate(input_ids, attention_mask=attention_mask) | summary = self.tokenizer.decode(output[0], skip_special_tokens=True) - | tuple_["$resultAttribute"] = summary - | yield tuple_""".stripMargin + | tuple_[$resultAttribute] = summary + | yield tuple_""".encode } override def operatorInfo: OperatorInfo = diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/Scorer/MachineLearningScorerOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/Scorer/MachineLearningScorerOpDesc.scala index 0020b547aee..a2f72a513ed 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/Scorer/MachineLearningScorerOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/Scorer/MachineLearningScorerOpDesc.scala @@ -26,6 +26,8 @@ import com.kjetland.jackson.jsonSchema.annotations.{ JsonSchemaTitle } import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, HideAnnotation} @@ -43,13 +45,13 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Actual Value") @JsonPropertyDescription("Specify the label attribute") @AutofillAttributeName - var actualValueColumn: String = "" + var actualValueColumn: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Predicted Value") @JsonPropertyDescription("Specify the attribute generated by the model") @AutofillAttributeName - var predictValueColumn: String = "" + var predictValueColumn: EncodableString = "" @JsonProperty(required = false, value = "classificationFlag") @JsonSchemaTitle("Scorer Functions") @@ -113,14 +115,14 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor { // scorer.getName() // } - private def getMetricName(metric: Any): String = + private def getMetricName(metric: Any): EncodableString = metric match { case m: regressionMetricsFnc => m.getName() case m: classificationMetricsFnc => m.getName() case _ => throw new IllegalArgumentException("Unknown metric type") } - private def getSelectedMetrics(): String = { + private def getSelectedMetrics(): EncodableString = { // Return a string of metrics using the getEachScorerName() method val metric = if (isRegression) regressionMetrics else classificationMetrics metric.map(metric => getMetricName(metric)).mkString("'", "','", "'") @@ -129,7 +131,7 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor { override def generatePythonCode(): String = { val isRegressionStr = if (isRegression) "True" else "False" val finalcode = - s""" + pyb""" |from pytexera import * |import pandas as pd |from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, mean_squared_error, root_mean_squared_error, mean_absolute_error, r2_score @@ -172,8 +174,8 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | y_true = table['$actualValueColumn'] - | y_pred = table['$predictValueColumn'] + | y_true = table[$actualValueColumn] + | y_pred = table[$predictValueColumn] | | metric_list = [${getSelectedMetrics()}] | @@ -185,8 +187,8 @@ class MachineLearningScorerOpDesc extends PythonOperatorDescriptor { | result = classification_metrics(y_true, y_pred, metric_list, labels) | | yield result - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/HyperParameters.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/HyperParameters.scala index f947ee69488..13fdb9aa60f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/HyperParameters.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/HyperParameters.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.machineLearning.sklearnAdvanced.base import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations._ +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.{ CommonOpDescAnnotation, HideAnnotation @@ -48,7 +49,7 @@ class HyperParameters[T] { ) ) @JsonProperty(value = "attribute") - var attribute: String = _ + var attribute: EncodableString = _ @JsonSchemaInject( strings = Array( @@ -59,7 +60,7 @@ class HyperParameters[T] { bools = Array(new JsonSchemaBool(path = HideAnnotation.hideOnNull, value = true)) ) @JsonProperty(value = "value") - var value: String = _ + var value: EncodableString = _ @JsonProperty(defaultValue = "false") @JsonSchemaTitle("Workflow") diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala index 189eb0be799..3127fa91232 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/machineLearning/sklearnAdvanced/base/SklearnAdvancedBaseDesc.scala @@ -22,6 +22,8 @@ package org.apache.texera.amber.operator.machineLearning.sklearnAdvanced.base import com.fasterxml.jackson.annotation.{JsonIgnore, JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{ @@ -29,6 +31,7 @@ import org.apache.texera.amber.operator.metadata.annotations.{ AutofillAttributeNameList } import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder trait ParamClass { def getName: String @@ -50,64 +53,53 @@ abstract class SklearnMLOperatorDescriptor[T <: ParamClass] extends PythonOperat @JsonSchemaTitle("Ground Truth Attribute Column") @JsonPropertyDescription("Ground truth attribute column") @AutofillAttributeName - var groundTruthAttribute: String = "" + var groundTruthAttribute: EncodableString = "" @JsonProperty(value = "Selected Features", required = true) @JsonSchemaTitle("Selected Features") @JsonPropertyDescription("Features used to train the model") @AutofillAttributeNameList - var selectedFeatures: List[String] = _ + var selectedFeatures: List[EncodableString] = _ - private def getLoopTimes(paraList: List[HyperParameters[T]]): String = { + private def getLoopTimes(paraList: List[HyperParameters[T]]): PythonTemplateBuilder = { for (ele <- paraList) { if (ele.parametersSource) { - return s"""table[\"${ele.attribute}\"].values.shape[0]""" + return pyb"""table[${ele.attribute}].values.shape[0]""" } } - "1" + pyb"1" } - def getParameter(paraList: List[HyperParameters[T]]): List[String] = { - var workflowParam = ""; - var portParam = ""; - var paramString = "" + def getParameter(paraList: List[HyperParameters[T]]): List[PythonTemplateBuilder] = { + var workflowParam = s""; + var portParam = pyb""; + var paramString = pyb"" for (ele <- paraList) { if (ele.parametersSource) { - workflowParam = workflowParam + String.format("%s = {},", ele.parameter.getName) + workflowParam = s"$workflowParam${ele.parameter.getName} = {}," portParam = - portParam + String.format( - "%s(table['%s'].values[i]),", - ele.parameter.getType, - ele.attribute - ) - paramString = paramString + String.format( - "%s = %s(table['%s'].values[i]),", - ele.parameter.getName, - ele.parameter.getType, - ele.attribute - ) + portParam + pyb"${ele.parameter.getType}(table[${ele.attribute}].values[i])," + paramString = + pyb"$paramString${ele.parameter.getName} = ${ele.parameter.getType}(table[${ele.attribute}].values[i])," } else { - workflowParam = workflowParam + String.format("%s = {},", ele.parameter.getName) - portParam = portParam + String.format("%s ('%s'),", ele.parameter.getType, ele.value) - paramString = paramString + String.format( - "%s = %s ('%s'),", - ele.parameter.getName, - ele.parameter.getType, - ele.value - ) + workflowParam = s"$workflowParam${ele.parameter.getName} = {}," + portParam = pyb"$portParam${ele.parameter.getType} (${ele.value})," + paramString = + pyb"$paramString${ele.parameter.getName} = ${ele.parameter.getType} (${ele.value})," } } - List(String.format("\"%s\".format(%s)", workflowParam, portParam), paramString) + List(pyb""""$workflowParam".format($portParam)""", paramString) + } override def generatePythonCode(): String = { - val listFeatures = selectedFeatures.map(feature => s""""$feature"""").mkString(",") + val listFeatures = selectedFeatures.map(feature => pyb"""$feature""").mkString(",") val trainingName = getImportStatements.split(" ").last val stringList = getParameter(paraList) val trainingParam = stringList(1) val paramString = stringList(0) val finalCode = - s""" + pyb""" |from pytexera import * | |import pandas as pd @@ -125,7 +117,7 @@ abstract class SklearnMLOperatorDescriptor[T <: ParamClass] extends PythonOperat | self.dataset = table | | if port == 1 : - | y_train = self.dataset["$groundTruthAttribute"] + | y_train = self.dataset[$groundTruthAttribute] | X_train = self.dataset[features] | loop_times = ${getLoopTimes(paraList)} | @@ -143,8 +135,8 @@ abstract class SklearnMLOperatorDescriptor[T <: ParamClass] extends PythonOperat | df = pd.DataFrame(data) | yield df | - |""".stripMargin - finalCode + |""" + finalCode.encode } override def operatorInfo: OperatorInfo = { diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnClassifierOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnClassifierOpDesc.scala index 3fcea191e18..0c8a103c52b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnClassifierOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnClassifierOpDesc.scala @@ -27,6 +27,8 @@ import com.kjetland.jackson.jsonSchema.annotations.{ JsonSchemaTitle } import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{ @@ -42,7 +44,7 @@ abstract class SklearnClassifierOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Attribute in your dataset corresponding to target.") @JsonProperty(required = true) @AutofillAttributeName - var target: String = _ + var target: EncodableString = _ @JsonSchemaTitle("Count Vectorizer") @JsonPropertyDescription("Convert a collection of text documents to a matrix of token counts.") @@ -65,7 +67,7 @@ abstract class SklearnClassifierOpDesc extends PythonOperatorDescriptor { new JsonSchemaInt(path = CommonOpDescAnnotation.autofillAttributeOnPort, value = 0) ) ) - var text: String = _ + var text: EncodableString = _ @JsonSchemaTitle("Tfidf Transformer") @JsonPropertyDescription("Transform a count matrix to a normalized tf or tf-idf representation.") @@ -86,7 +88,7 @@ abstract class SklearnClassifierOpDesc extends PythonOperatorDescriptor { def getUserFriendlyModelName = "" override def generatePythonCode(): String = - s"""$getImportStatements + pyb"""$getImportStatements |from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score |from sklearn.pipeline import make_pipeline |from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer @@ -95,9 +97,9 @@ abstract class SklearnClassifierOpDesc extends PythonOperatorDescriptor { |class ProcessTableOperator(UDFTableOperator): | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | Y = table["$target"] - | X = table.drop("$target", axis=1) - | X = ${if (countVectorizer) "X['" + text + "']" else "X"} + | Y = table[$target] + | X = table.drop($target, axis=1) + | X = ${if (countVectorizer) pyb"X[$text]" else "X"} | if port == 0: | self.model = make_pipeline(${if (countVectorizer) "CountVectorizer()," else ""} ${if (tfidfTransformer) "TfidfTransformer()," else ""} ${getImportStatements @@ -111,7 +113,7 @@ abstract class SklearnClassifierOpDesc extends PythonOperatorDescriptor { | recalls = recall_score(Y, predictions, average=None) | for i, class_name in enumerate(np.unique(Y)): | print("Class", repr(class_name), " - F1:", round(f1s[i], 4), ", Precision:", round(precisions[i], 4), ", Recall:", round(recalls[i], 4)) - | yield {"model_name" : "$getUserFriendlyModelName", "model" : self.model}""".stripMargin + | yield {"model_name" : "$getUserFriendlyModelName", "model" : self.model}""".encode override def operatorInfo: OperatorInfo = OperatorInfo( diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnLinearRegressionOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnLinearRegressionOpDesc.scala index 1c4c7e6288c..f99da2bff47 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnLinearRegressionOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnLinearRegressionOpDesc.scala @@ -22,6 +22,8 @@ package org.apache.texera.amber.operator.sklearn import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -33,7 +35,7 @@ class SklearnLinearRegressionOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Attribute in your dataset corresponding to target.") @JsonProperty(required = true) @AutofillAttributeName - var target: String = _ + var target: EncodableString = _ @JsonSchemaTitle("Degree") @JsonPropertyDescription("Degree of polynomial function") @@ -41,7 +43,7 @@ class SklearnLinearRegressionOpDesc extends PythonOperatorDescriptor { val degree: Int = 1 override def generatePythonCode(): String = - s""" + pyb""" |from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, mean_absolute_error, r2_score |from sklearn.pipeline import make_pipeline |from sklearn.linear_model import LinearRegression @@ -51,8 +53,8 @@ class SklearnLinearRegressionOpDesc extends PythonOperatorDescriptor { |class ProcessTableOperator(UDFTableOperator): | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | Y = table["$target"] - | X = table.drop("$target", axis=1) + | Y = table[$target] + | X = table.drop($target, axis=1) | if port == 0: | pipeline = make_pipeline( | PolynomialFeatures(degree=$degree), @@ -64,7 +66,7 @@ class SklearnLinearRegressionOpDesc extends PythonOperatorDescriptor { | mae = round(mean_absolute_error(Y, predictions), 4) | r2 = round(r2_score(Y, predictions), 4) | print("MAE:", mae, ", R2:", r2) - | yield {"model_name" : "LinearRegression", "model" : self.model}""".stripMargin + | yield {"model_name" : "LinearRegression", "model" : self.model}""".encode override def operatorInfo: OperatorInfo = OperatorInfo( diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnPredictionOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnPredictionOpDesc.scala index 0e0a5772f36..92557fa78df 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnPredictionOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/SklearnPredictionOpDesc.scala @@ -21,6 +21,8 @@ package org.apache.texera.amber.operator.sklearn import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{ @@ -33,11 +35,11 @@ class SklearnPredictionOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "Model Attribute", required = true, defaultValue = "model") @JsonPropertyDescription("attribute corresponding to ML model") @AutofillAttributeName - var model: String = _ + var model: EncodableString = _ @JsonProperty(value = "Output Attribute Name", required = true, defaultValue = "prediction") @JsonPropertyDescription("attribute name of the prediction result") - var resultAttribute: String = _ + var resultAttribute: EncodableString = _ @JsonProperty( value = "Ground Truth Attribute Name to Ignore", @@ -46,24 +48,24 @@ class SklearnPredictionOpDesc extends PythonOperatorDescriptor { ) @JsonPropertyDescription("attribute name of the ground truth") @AutofillAttributeNameOnPort1 - var groundTruthAttribute: String = "" + var groundTruthAttribute: EncodableString = "" override def generatePythonCode(): String = - s"""from pytexera import * + pyb"""from pytexera import * |from sklearn.pipeline import Pipeline |class ProcessTupleOperator(UDFOperatorV2): | @overrides | def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: | if port == 0: - | self.model = tuple_["$model"] + | self.model = tuple_[$model] | else: | input_features = tuple_ - | if "$groundTruthAttribute" != "": - | input_features = input_features.get_partial_tuple([col for col in tuple_.get_field_names() if col != "$groundTruthAttribute"]) - | tuple_["$resultAttribute"] = type(tuple_["$groundTruthAttribute"])(self.model.predict(Table.from_tuple_likes([input_features]))[0]) + | if $groundTruthAttribute != "": + | input_features = input_features.get_partial_tuple([col for col in tuple_.get_field_names() if col != $groundTruthAttribute]) + | tuple_[$resultAttribute] = type(tuple_[$groundTruthAttribute])(self.model.predict(Table.from_tuple_likes([input_features]))[0]) | else: - | tuple_["$resultAttribute"] = str(self.model.predict(Table.from_tuple_likes([input_features]))[0]) - | yield tuple_""".stripMargin + | tuple_[$resultAttribute] = str(self.model.predict(Table.from_tuple_likes([input_features]))[0]) + | yield tuple_""".encode override def operatorInfo: OperatorInfo = OperatorInfo( diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/training/SklearnTrainingOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/training/SklearnTrainingOpDesc.scala index c842ffafac3..c0d1fd3a511 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/training/SklearnTrainingOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sklearn/training/SklearnTrainingOpDesc.scala @@ -27,6 +27,8 @@ import com.kjetland.jackson.jsonSchema.annotations.{ JsonSchemaTitle } import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{ @@ -42,7 +44,7 @@ class SklearnTrainingOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Attribute in your dataset corresponding to target.") @JsonProperty(required = true) @AutofillAttributeName - var target: String = _ + var target: EncodableString = _ @JsonSchemaTitle("Count Vectorizer") @JsonPropertyDescription("Convert a collection of text documents to a matrix of token counts.") @@ -65,7 +67,7 @@ class SklearnTrainingOpDesc extends PythonOperatorDescriptor { new JsonSchemaInt(path = CommonOpDescAnnotation.autofillAttributeOnPort, value = 0) ) ) - var text: String = _ + var text: EncodableString = _ @JsonSchemaTitle("Tfidf Transformer") @JsonPropertyDescription("Transform a count matrix to a normalized tf or tf-idf representation.") @@ -86,7 +88,7 @@ class SklearnTrainingOpDesc extends PythonOperatorDescriptor { def getUserFriendlyModelName = "RandomForest Training" override def generatePythonCode(): String = - s"""$getImportStatements + pyb"""$getImportStatements |from sklearn.pipeline import make_pipeline |from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer |import numpy as np @@ -94,16 +96,16 @@ class SklearnTrainingOpDesc extends PythonOperatorDescriptor { |class ProcessTableOperator(UDFTableOperator): | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | Y = table["$target"] - | X = table.drop("$target", axis=1) - | X = ${if (countVectorizer) "X['" + text + "']" else "X"} + | Y = table[$target] + | X = table.drop($target, axis=1) + | X = ${if (countVectorizer) "X[" + text + "]" else "X"} | model = make_pipeline(${if (countVectorizer) "CountVectorizer()," else ""} ${if ( tfidfTransformer ) "TfidfTransformer()," else ""} ${getImportStatements.split(" ").last}()).fit(X, Y) | yield {"model_name" : "$getUserFriendlyModelName", "model" : model} | - | """.stripMargin + | """.encode override def operatorInfo: OperatorInfo = OperatorInfo( diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortCriteriaUnit.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortCriteriaUnit.scala index b4bae266ef9..f0e851110b8 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortCriteriaUnit.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortCriteriaUnit.scala @@ -20,6 +20,7 @@ package org.apache.texera.amber.operator.sort import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName class SortCriteriaUnit { @@ -27,7 +28,7 @@ class SortCriteriaUnit { @JsonProperty(value = "attribute", required = true) @JsonPropertyDescription("Attribute name to sort by") @AutofillAttributeName - var attributeName: String = _ + var attributeName: EncodableString = _ @JsonProperty(value = "sortPreference", required = true) @JsonPropertyDescription("Sort preference (ASC or DESC)") diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala index 2e46fb81333..0825422e6c1 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/sort/SortOpDesc.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.sort import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.Schema +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} @@ -32,7 +33,7 @@ class SortOpDesc extends PythonOperatorDescriptor { override def generatePythonCode(): String = { val attributeName = "[" + attributes .map { criteria => - s""""${criteria.attributeName}"""" + pyb"""${criteria.attributeName}""" } .mkString(", ") + "]" val sortOrders: String = "[" + attributes @@ -44,7 +45,7 @@ class SortOpDesc extends PythonOperatorDescriptor { } .mkString(", ") + "]" - s"""from pytexera import * + pyb"""from pytexera import * |import pandas as pd |from datetime import datetime | @@ -56,7 +57,7 @@ class SortOpDesc extends PythonOperatorDescriptor { | ascending_orders = $sortOrders | | sorted_df = table.sort_values(by=sort_columns, ascending=ascending_orders) - | yield sorted_df""".stripMargin + | yield sorted_df""".encode } def getOutputSchemas(inputSchemas: Map[PortIdentity, Schema]): Map[PortIdentity, Schema] = { diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/apis/reddit/RedditSearchSourceOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/apis/reddit/RedditSearchSourceOpDesc.scala index adaef827180..85cf30bf06b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/apis/reddit/RedditSearchSourceOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/apis/reddit/RedditSearchSourceOpDesc.scala @@ -22,6 +22,8 @@ package org.apache.texera.amber.operator.source.apis.reddit import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{OutputPort, PortIdentity} import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.operator.source.PythonSourceOperatorDescriptor @@ -30,17 +32,17 @@ class RedditSearchSourceOpDesc extends PythonSourceOperatorDescriptor { @JsonProperty(required = true) @JsonSchemaTitle("Client Id") @JsonPropertyDescription("Client id that uses to access Reddit API") - var clientId: String = _ + var clientId: EncodableString = _ @JsonProperty(required = true) @JsonSchemaTitle("Client Secret") @JsonPropertyDescription("Client secret that uses to access Reddit API") - var clientSecret: String = _ + var clientSecret: EncodableString = _ @JsonProperty(required = true) @JsonSchemaTitle("Query") @JsonPropertyDescription("Search query") - var query: String = _ + var query: EncodableString = _ @JsonProperty(required = true, defaultValue = "100") @JsonSchemaTitle("Limit") @@ -53,20 +55,20 @@ class RedditSearchSourceOpDesc extends PythonSourceOperatorDescriptor { var sorting: RedditSourceOperatorFunction = _ override def generatePythonCode(): String = { - val clientIdReal = this.clientId.replace("\n", "").trim - val clientSecretReal = this.clientSecret.replace("\n", "").trim - val queryReal = this.query.replace("\n", "").trim + val clientIdReal: EncodableString = this.clientId.replace("\n", "").trim + val clientSecretReal: EncodableString = this.clientSecret.replace("\n", "").trim + val queryReal: EncodableString = this.query.replace("\n", "").trim - s"""from pytexera import * + pyb"""from pytexera import * |import praw |from datetime import datetime | |class ProcessTupleOperator(UDFSourceOperator): - | client_id = '$clientIdReal' - | client_secret = '$clientSecretReal' + | client_id = $clientIdReal + | client_secret = $clientSecretReal | limit = $limit - | query = '$queryReal' - | sorting = '${sorting.getName}' + | query = $queryReal + | sorting = ${sorting.getName} | | @overrides | def produce(self) -> Iterator[Union[TupleLike, TableLike, None]]: @@ -116,7 +118,7 @@ class RedditSearchSourceOpDesc extends PythonSourceOperatorDescriptor { | 'author_name': author.name, | 'subreddit': subreddit | }) - | yield tuple_submission""".stripMargin + | yield tuple_submission""".encode } override def operatorInfo: OperatorInfo = diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesPlot.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesPlot.scala index aae1c339331..0fdcb09a5cb 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesPlot.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesPlot.scala @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -36,7 +38,7 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The column containing time/date values (e.g., Date, Timestamp).") @AutofillAttributeName @NotNull(message = "Time Column cannot be empty") - var timeColumn: String = "" + var timeColumn: EncodableString = "" @JsonProperty(value = "valueColumn", required = true) @JsonSchemaTitle("Value Column") @@ -44,19 +46,19 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { @JsonSchemaInject(json = """{"enum": "autofill"}""") @AutofillAttributeName @NotNull(message = "Value Column cannot be empty") - var valueColumn: String = "" + var valueColumn: EncodableString = "" @JsonProperty(value = "categoryColumn", required = false, defaultValue = "No Selection") @JsonSchemaTitle("Category Column") @JsonPropertyDescription("Optional - A categorical column to create separate lines.") @AutofillAttributeName - var CategoryColumn: String = "No Selection" + var CategoryColumn: EncodableString = "No Selection" @JsonProperty(value = "facetColumn", required = false, defaultValue = "No Selection") @JsonSchemaTitle("Facet Column") @JsonPropertyDescription("Optional - A column to create separate subplots.") @AutofillAttributeName - var facetColumn: String = "No Selection" + var facetColumn: EncodableString = "No Selection" @JsonProperty(value = "line", defaultValue = "line", required = true) @JsonSchemaTitle("Plot Type") @@ -89,14 +91,14 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { val dropnaCols = List(timeColumn, valueColumn) ++ (if (CategoryColumn != "No Selection") Some(CategoryColumn) else None) ++ (if (facetColumn != "No Selection") Some(facetColumn) else None) - val dropnaStr = dropnaCols.map(c => s"'$c'").mkString("[", ", ", "]") + val dropnaStr = dropnaCols.map(c => pyb"$c").mkString("[", ", ", "]") - val colorArg = if (CategoryColumn != "No Selection") s", color='$CategoryColumn'" else "" - val facetArg = if (facetColumn != "No Selection") s", facet_col='$facetColumn'" else "" + val colorArg = if (CategoryColumn != "No Selection") pyb", color=$CategoryColumn" else "" + val facetArg = if (facetColumn != "No Selection") pyb", facet_col=$facetColumn" else "" val plotFunc = if (plotType == "area") "px.area" else "px.line" val showSlider = if (showRangeSlider) "True" else "False" - s""" + pyb""" |from pytexera import * |import plotly.express as px |import plotly.io @@ -114,14 +116,14 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { | return | | try: - | table['$timeColumn'] = pd.to_datetime(table['$timeColumn'], errors='coerce') - | table = table.dropna(subset=$dropnaStr).sort_values(by='$timeColumn') + | table[$timeColumn] = pd.to_datetime(table[$timeColumn], errors='coerce') + | table = table.dropna(subset=$dropnaStr).sort_values(by=$timeColumn) | | if table.empty: | yield {'html-content': self.render_error("Table became empty after filtering.")} | return | - | fig = $plotFunc(table, x='$timeColumn', y='$valueColumn'$colorArg$facetArg) + | fig = $plotFunc(table, x=$timeColumn, y=$valueColumn$colorArg$facetArg) | | if $showSlider: | fig.update_xaxes(rangeslider_visible=True) @@ -129,8 +131,8 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { | fig.update_layout( | margin=dict(l=0, r=0, t=30, b=0), | title=dict(text="Time Series Plot", x=0.5), - | xaxis_title="$timeColumn", - | yaxis_title="$valueColumn", + | xaxis_title=$timeColumn, + | yaxis_title=$valueColumn, | template="plotly_white" | ) | @@ -139,6 +141,6 @@ class TimeSeriesOpDesc extends PythonOperatorDescriptor { | | except Exception as e: | yield {'html-content': self.render_error(str(e))} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDesc.scala index bf2e152c79d..33069a89212 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -37,7 +40,7 @@ class DotPlotOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("the attribute for the counting of the dot plot") @AutofillAttributeName @NotNull(message = "Count Attribute column cannot be empty") - var countAttribute: String = "" + var countAttribute: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -57,21 +60,21 @@ class DotPlotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { - s""" - | table = table.groupby(['$countAttribute'])['$countAttribute'].count().reset_index(name='counts') - | fig = px.strip(table, x='counts', y='$countAttribute', orientation='h', color='$countAttribute', + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" + | table = table.groupby([$countAttribute])[$countAttribute].count().reset_index(name='counts') + | fig = px.strip(table, x='counts', y=$countAttribute, orientation='h', color=$countAttribute, | color_discrete_sequence=px.colors.qualitative.Dark2) | | fig.update_traces(marker=dict(size=12, line=dict(width=2, color='DarkSlateGrey'))) | | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -97,8 +100,8 @@ class DotPlotOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/IcicleChart/IcicleChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/IcicleChart/IcicleChartOpDesc.scala index 7705194685a..a39e9b2681c 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/IcicleChart/IcicleChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/IcicleChart/IcicleChartOpDesc.scala @@ -23,11 +23,14 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.operator.visualization.hierarchychart.HierarchySection +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.{NotEmpty, NotNull} @@ -55,7 +58,7 @@ class IcicleChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("the value associated with the size of each sector in the chart") @AutofillAttributeName @NotNull(message = "Value column cannot be empty") - var value: String = "" + var value: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -76,29 +79,29 @@ class IcicleChartOpDesc extends PythonOperatorDescriptor { ) private def getIcicleAttributesInPython: String = - hierarchy.map(_.attributeName).mkString("'", "','", "'") + hierarchy.map(c => pyb"${c.attributeName}").mkString(",") - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { val attributes = getIcicleAttributesInPython - s""" - | table['$value'] = table[table['$value'] > 0]['$value'] # remove non-positive numbers from the data + pyb""" + | table[$value] = table[table[$value] > 0][$value] # remove non-positive numbers from the data | table.dropna(subset = [$attributes], inplace = True) #remove missing values - |""".stripMargin + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(hierarchy.nonEmpty) val attributes = getIcicleAttributesInPython - s""" - | fig = px.icicle(table, path=[$attributes], values='$value', - | color='$value', hover_data=[$attributes], + pyb""" + | fig = px.icicle(table, path=[$attributes], values=$value, + | color=$value, hover_data=[$attributes], | color_continuous_scale='RdBu') - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -128,8 +131,8 @@ class IcicleChartOpDesc extends PythonOperatorDescriptor { | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala index 2f1b9a5e970..dc9eec9a8ee 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ImageViz/ImageVisualizerOpDesc.scala @@ -23,17 +23,20 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class ImageVisualizerOpDesc extends PythonOperatorDescriptor { @JsonProperty(required = true) @JsonSchemaTitle("image content column") @JsonPropertyDescription("The Binary data of the Image") @AutofillAttributeName - var binaryContent: String = _ + var binaryContent: EncodableString = _ override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -53,16 +56,16 @@ class ImageVisualizerOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createBinaryData(): String = { + def createBinaryData(): PythonTemplateBuilder = { assert(binaryContent.nonEmpty) - s""" - | binary_image_data = tuple_['$binaryContent'] - |""".stripMargin + pyb""" + | binary_image_data = tuple_[$binaryContent] + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * |import base64 |from io import BytesIO @@ -92,8 +95,8 @@ class ImageVisualizerOpDesc extends PythonOperatorDescriptor { | def on_finish(self, port: int) -> Iterator[Optional[TupleLike]]: | all_images_html = "
" + "".join(self.images_html) + "
" | yield {"html-content": all_images_html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ScatterMatrixChart/ScatterMatrixChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ScatterMatrixChart/ScatterMatrixChartOpDesc.scala index bcb159a8119..3bfc5eb6b68 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ScatterMatrixChart/ScatterMatrixChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ScatterMatrixChart/ScatterMatrixChartOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.{ @@ -30,6 +32,7 @@ import org.apache.texera.amber.operator.metadata.annotations.{ AutofillAttributeNameList } import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonSchemaInject(json = """ { "attributeTypeRules": { @@ -45,13 +48,13 @@ class ScatterMatrixChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Selected Attributes") @JsonPropertyDescription("The axes of each scatter plot in the matrix.") @AutofillAttributeNameList - var selectedAttributes: List[String] = _ + var selectedAttributes: List[EncodableString] = _ @JsonProperty(value = "Color", required = true) @JsonSchemaTitle("Color Column") @JsonPropertyDescription("Column to color points") @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -71,20 +74,20 @@ class ScatterMatrixChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(selectedAttributes.nonEmpty) - val list_Attributes = selectedAttributes.map(attribute => s""""$attribute"""").mkString(",") - s""" - | fig = px.scatter_matrix(table, dimensions=[$list_Attributes], color='$color') + val list_Attributes = selectedAttributes.map(attribute => pyb"""$attribute""").mkString(",") + pyb""" + | fig = px.scatter_matrix(table, dimensions=[$list_Attributes], color=$color) | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -101,8 +104,8 @@ class ScatterMatrixChartOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDesc.scala index c2a8740c30e..723b64bd3d0 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -47,20 +50,20 @@ class BarChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The value associated with each category") @AutofillAttributeName @NotNull(message = "Value column cannot be empty") - var value: String = "" + var value: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Fields") @JsonPropertyDescription("Visualize categorical data in a Bar Chart") @AutofillAttributeName @NotNull(message = "Fields cannot be empty") - var fields: String = "" + var fields: EncodableString = "" @JsonProperty(defaultValue = "No Selection", required = false) @JsonSchemaTitle("Category Column") @JsonPropertyDescription("Optional - Select a column to Color Code the Categories") @AutofillAttributeName - var categoryColumn: String = "" + var categoryColumn: EncodableString = "" @JsonProperty(defaultValue = "false") @JsonSchemaTitle("Horizontal Orientation") @@ -71,7 +74,7 @@ class BarChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Pattern") @JsonPropertyDescription("Add texture to the chart based on an attribute") @AutofillAttributeName - var pattern: String = "" + var pattern: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -91,12 +94,12 @@ class BarChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(value.nonEmpty, "Value column cannot be empty") assert(fields.nonEmpty, "Fields cannot be empty") - s""" - | table = table.dropna(subset = ['$value', '$fields']) #remove missing values - |""".stripMargin + pyb""" + | table = table.dropna(subset = [$value, $fields]) #remove missing values + |""" } override def generatePythonCode(): String = { @@ -114,7 +117,7 @@ class BarChartOpDesc extends PythonOperatorDescriptor { isCategoryColumn = "True" val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -136,22 +139,22 @@ class BarChartOpDesc extends PythonOperatorDescriptor { | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: | ${manipulateTable()} - | if not table.empty and '$fields' != '$value': + | if not table.empty and $fields != $value: | if $isHorizontalOrientation: - | fig = go.Figure(px.bar(table, y='$fields', x='$value', color="$categoryColumn" if $isCategoryColumn else None, pattern_shape="$pattern" if $isPatternSelected else None, orientation = 'h')) + | fig = go.Figure(px.bar(table, y=$fields, x=$value, color=$categoryColumn if $isCategoryColumn else None, pattern_shape=$pattern if $isPatternSelected else None, orientation = 'h')) | else: - | fig = go.Figure(px.bar(table, y='$value', x='$fields', color="$categoryColumn" if $isCategoryColumn else None, pattern_shape="$pattern" if $isPatternSelected else None)) + | fig = go.Figure(px.bar(table, y=$value, x=$fields, color=$categoryColumn if $isCategoryColumn else None, pattern_shape=$pattern if $isPatternSelected else None)) | fig.update_layout(margin=dict(l=0, r=0, t=0, b=0)) | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) | # use latest plotly lib in html | #html = html.replace('https://cdn.plot.ly/plotly-2.3.1.min.js', 'https://cdn.plot.ly/plotly-2.18.2.min.js') - | elif '$fields' == '$value': + | elif $fields == $value: | html = self.render_error('Fields should not have the same value.') | elif table.empty: | html = self.render_error('Table should not have any empty/null values or fields.') | yield {'html-content':html} - | """.stripMargin - finalCode + | """ + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/boxViolinPlot/BoxViolinPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/boxViolinPlot/BoxViolinPlotOpDesc.scala index 5f57f17937c..9f3a2a1f31e 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/boxViolinPlot/BoxViolinPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/boxViolinPlot/BoxViolinPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription, import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonPropertyOrder(Array("value", "quartileType", "horizontalOrientation", "violinPlot")) @JsonSchemaInject(json = """ @@ -44,7 +47,7 @@ class BoxViolinPlotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Value Column") @JsonPropertyDescription("Data column for box plot") @AutofillAttributeName - var value: String = "" + var value: EncodableString = "" @JsonProperty( value = "Quartile Method", @@ -83,38 +86,38 @@ class BoxViolinPlotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(value.nonEmpty) - s""" - | table = table.dropna(subset = ['$value']) #remove missing values - | - |""".stripMargin + pyb""" + | table = table.dropna(subset = [$value]) #remove missing values + | + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { val horizontal = if (horizontalOrientation) "True" else "False" val violin = if (violinPlot) "True" else "False" - s""" + pyb""" | if($violin): | if ($horizontal): - | fig = px.violin(table, x='$value', box=True, points='all') + | fig = px.violin(table, x=$value, box=True, points='all') | else: - | fig = px.violin(table, y='$value', box=True, points='all') + | fig = px.violin(table, y=$value, box=True, points='all') | else: | if($horizontal): - | fig = px.box(table, x='$value',boxmode="overlay", points='all') + | fig = px.box(table, x=$value,boxmode="overlay", points='all') | else: - | fig = px.box(table, y='$value',boxmode="overlay", points='all') + | fig = px.box(table, y=$value,boxmode="overlay", points='all') | fig.update_traces(quartilemethod="${quartileType.getQuartiletype}", col=1) | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -146,8 +149,8 @@ class BoxViolinPlotOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - | """.stripMargin - finalCode + | """ + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDesc.scala index 3b95d95158a..59a8cf5cc89 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -44,21 +47,21 @@ class BubbleChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Data column for the x-axis") @AutofillAttributeName @NotNull(message = "xValue column cannot be empty") - var xValue: String = "" + var xValue: EncodableString = "" @JsonProperty(value = "yValue", required = true) @JsonSchemaTitle("Y-Column") @JsonPropertyDescription("Data column for the y-axis") @AutofillAttributeName @NotNull(message = "yValue column cannot be empty") - var yValue: String = "" + var yValue: EncodableString = "" @JsonProperty(value = "zValue", required = true) @JsonSchemaTitle("Z-Column") @JsonPropertyDescription("Data column to determine bubble size") @AutofillAttributeName @NotNull(message = "zValue column cannot be empty") - var zValue: String = "" + var zValue: EncodableString = "" @JsonProperty(value = "enableColor", defaultValue = "false") @JsonSchemaTitle("Enable Color") @@ -70,7 +73,7 @@ class BubbleChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("Picks data column to color bubbles with if color is enabled") @AutofillAttributeName @NotNull(message = "colorCategory column cannot be empty") - var colorCategory: String = "" + var colorCategory: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -90,28 +93,28 @@ class BubbleChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(xValue.nonEmpty && yValue.nonEmpty && zValue.nonEmpty) - s""" + pyb""" | # drops rows with missing values pertaining to relevant columns - | table.dropna(subset=['$xValue', '$yValue', '$zValue'], inplace = True) + | table.dropna(subset=[$xValue, $yValue, $zValue], inplace = True) | - |""".stripMargin + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(xValue.nonEmpty && yValue.nonEmpty && zValue.nonEmpty) - s""" - | if '$enableColor' == 'true': - | fig = go.Figure(px.scatter(table, x='$xValue', y='$yValue', size='$zValue', size_max=100, color='$colorCategory')) - | else: - | fig = go.Figure(px.scatter(table, x='$xValue', y='$yValue', size='$zValue', size_max=100)) - |""".stripMargin + pyb""" + | if $enableColor == 'true': + | fig = go.Figure(px.scatter(table, x=$xValue, y=$yValue, size=$zValue, size_max=100, color=$colorCategory)) + | else: + | fig = go.Figure(px.scatter(table, x=$xValue, y=$yValue, size=$zValue, size_max=100)) + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -140,7 +143,7 @@ class BubbleChartOpDesc extends PythonOperatorDescriptor { | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) | yield {'html-content':html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartOpDesc.scala index 93bb11db4b2..a5e19bca9a5 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -40,17 +42,17 @@ class BulletChartOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "value", required = true) @JsonSchemaTitle("Value") @JsonPropertyDescription("The actual value to display on the bullet chart") - @AutofillAttributeName var value: String = "" + @AutofillAttributeName var value: EncodableString = "" @JsonProperty(value = "deltaReference", required = true) @JsonSchemaTitle("Delta Reference") @JsonPropertyDescription("The reference value for the delta indicator. e.g., 100") - var deltaReference: String = "" + var deltaReference: EncodableString = "" @JsonProperty(value = "thresholdValue", required = false) @JsonSchemaTitle("Threshold Value") @JsonPropertyDescription("The performance threshold value. e.g., 100") - var thresholdValue: String = "" + var thresholdValue: EncodableString = "" @JsonProperty(value = "steps", required = false) @JsonSchemaTitle("Steps") @@ -78,14 +80,14 @@ class BulletChartOpDesc extends PythonOperatorDescriptor { // Convert the Scala list of steps into a list of dictionaries val stepsStr = if (steps != null && !steps.isEmpty) { val stepsSeq = - steps.asScala.map(step => s"""{"start": "${step.start}", "end": "${step.end}"}""") + steps.asScala.map(step => pyb"""{"start": ${step.start}, "end": ${step.end}}""") "[" + stepsSeq.mkString(", ") + "]" } else { "[]" } val finalCode = - s""" + pyb""" |from pytexera import * |import plotly.graph_objects as go |import plotly.io as pio @@ -133,8 +135,8 @@ class BulletChartOpDesc extends PythonOperatorDescriptor { | return | | try: - | value_col = "$value" - | delta_ref = float("$deltaReference") if "$deltaReference".strip() else 0 + | value_col = $value + | delta_ref = float($deltaReference) if $deltaReference.strip() else 0 | | if value_col not in table.columns: | yield {'html-content': self.render_error(f"Column '{value_col}' not found in input table.")} @@ -146,7 +148,7 @@ class BulletChartOpDesc extends PythonOperatorDescriptor { | return | | try: - | threshold_val = float("$thresholdValue") if "$thresholdValue".strip() else None + | threshold_val = float($thresholdValue) if $thresholdValue.strip() else None | except ValueError: | threshold_val = None | @@ -225,7 +227,7 @@ class BulletChartOpDesc extends PythonOperatorDescriptor { | yield {"html-content": final_html} | except Exception as e: | yield {'html-content': self.render_error(f"General error: {str(e)}")} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinition.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinition.scala index 0fdc9989928..5ff0ad89537 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinition.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinition.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.bulletChart import com.fasterxml.jackson.annotation.{JsonCreator, JsonProperty} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString /** * Defines a step range used for qualitative segments in the Bullet Chart. @@ -29,8 +30,8 @@ import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle class BulletChartStepDefinition @JsonCreator() ( @JsonProperty("start") @JsonSchemaTitle("Start") - var start: String, + var start: EncodableString, @JsonProperty("end") @JsonSchemaTitle("End") - var end: String + var end: EncodableString ) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDesc.scala index 62156031213..f0cc02a9b7d 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -34,31 +36,31 @@ class CandlestickChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Date Column") @JsonPropertyDescription("the date of the candlestick") @AutofillAttributeName - var date: String = "" + var date: EncodableString = "" @JsonProperty(value = "open", required = true) @JsonSchemaTitle("Opening Price Column") @JsonPropertyDescription("the opening price of the candlestick") @AutofillAttributeName - var open: String = "" + var open: EncodableString = "" @JsonProperty(value = "high", required = true) @JsonSchemaTitle("Highest Price Column") @JsonPropertyDescription("the highest price of the candlestick") @AutofillAttributeName - var high: String = "" + var high: EncodableString = "" @JsonProperty(value = "low", required = true) @JsonSchemaTitle("Lowest Price Column") @JsonPropertyDescription("the lowest price of the candlestick") @AutofillAttributeName - var low: String = "" + var low: EncodableString = "" @JsonProperty(value = "close", required = true) @JsonSchemaTitle("Closing Price Column") @JsonPropertyDescription("the closing price of the candlestick") @AutofillAttributeName - var close: String = "" + var close: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -79,7 +81,7 @@ class CandlestickChartOpDesc extends PythonOperatorDescriptor { ) override def generatePythonCode(): String = { - s""" + pyb""" |from pytexera import * | |import plotly.graph_objects as go @@ -96,16 +98,16 @@ class CandlestickChartOpDesc extends PythonOperatorDescriptor { | df = pd.DataFrame(table_dict) | | fig = go.Figure(data=[go.Candlestick( - | x=df['$date'], - | open=df['$open'], - | high=df['$high'], - | low=df['$low'], - | close=df['$close'] + | x=df[$date], + | open=df[$open], + | high=df[$high], + | low=df[$low], + | close=df[$close] | )]) | fig.update_layout(title='Candlestick Chart') | html = fig.to_html(include_plotlyjs='cdn', full_html=False) | yield {'html-content': html} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/choroplethMap/ChoroplethMapOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/choroplethMap/ChoroplethMapOpDesc.scala index 629739dcd15..f9774ce80f4 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/choroplethMap/ChoroplethMapOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/choroplethMap/ChoroplethMapOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonSchemaInject(json = """ { @@ -48,7 +51,7 @@ class ChoroplethMapOpDesc extends PythonOperatorDescriptor { "Column used to describe location. Currently only supports countries and needs to be three-letter ISO country code" ) @AutofillAttributeName - var locations: String = "" + var locations: EncodableString = "" @JsonProperty(value = "color", required = true) @JsonSchemaTitle("Color Column") @@ -56,7 +59,7 @@ class ChoroplethMapOpDesc extends PythonOperatorDescriptor { "Column used to determine intensity of color of the region" ) @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -75,25 +78,25 @@ class ChoroplethMapOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(locations.nonEmpty) assert(color.nonEmpty) - s""" - | table.dropna(subset=['$locations', '$color'], inplace = True) - |""".stripMargin + pyb""" + | table.dropna(subset=[$locations, $color], inplace = True) + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(locations.nonEmpty && color.nonEmpty) - s""" - | fig = px.choropleth(table, locations="$locations", color="$color", color_continuous_scale=px.colors.sequential.Plasma) - | fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0}) - |""".stripMargin + pyb""" + | fig = px.choropleth(table, locations=$locations, color=$color, color_continuous_scale=px.colors.sequential.Plasma) + | fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0}) + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -120,7 +123,7 @@ class ChoroplethMapOpDesc extends PythonOperatorDescriptor { | ${createPlotlyFigure()} | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfig.scala index c4ae7f7ae02..7bd602eeb18 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfig.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.continuousErrorBands import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.visualization.lineChart.LineConfig @@ -30,16 +31,16 @@ class BandConfig extends LineConfig { @JsonSchemaTitle("Y-Axis Upper Bound") @JsonPropertyDescription("Represents upper bound error of y-values") @AutofillAttributeName - var yUpper: String = "" + var yUpper: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Y-Axis Lower Bound") @JsonPropertyDescription("Represents lower bound error of y-values") @AutofillAttributeName - var yLower: String = "" + var yLower: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Fill Color") @JsonPropertyDescription("must be a valid CSS color or hex color string") - var fillColor: String = "" + var fillColor: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/ContinuousErrorBandsOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/ContinuousErrorBandsOpDesc.scala index dd343ca5f68..b21c882ed35 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/ContinuousErrorBandsOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/ContinuousErrorBandsOpDesc.scala @@ -23,9 +23,12 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import java.util import scala.jdk.CollectionConverters.ListHasAsScala @@ -34,12 +37,12 @@ class ContinuousErrorBandsOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "xLabel", required = false, defaultValue = "X Axis") @JsonSchemaTitle("X Label") @JsonPropertyDescription("Label used for x axis") - var xLabel: String = "" + var xLabel: EncodableString = "" @JsonProperty(value = "yLabel", required = false, defaultValue = "Y Axis") @JsonSchemaTitle("Y Label") @JsonPropertyDescription("Label used for y axis") - var yLabel: String = "" + var yLabel: EncodableString = "" @JsonProperty(value = "bands", required = true) var bands: util.List[BandConfig] = _ @@ -62,30 +65,30 @@ class ContinuousErrorBandsOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { val bandsPart = bands.asScala .map { bandConf => val colorPart = if (bandConf.color != "") { - s"line={'color':'${bandConf.color}'}, marker={'color':'${bandConf.color}'}, " + pyb"line={'color':${bandConf.color}}, marker={'color':${bandConf.color}}, " } else { "" } val fillColorPart = if (bandConf.fillColor != "") { - s"fillcolor='${bandConf.fillColor}', " + pyb"fillcolor=${bandConf.fillColor}, " } else { "" } val namePart = if (bandConf.name != "") { - s"name='${bandConf.name}'" + pyb"name=${bandConf.name}" } else { - s"name='${bandConf.yValue}'" + pyb"name=${bandConf.yValue}" } - s"""fig.add_trace(go.Scatter( - x=table['${bandConf.xValue}'], - y=table['${bandConf.yUpper}'], + pyb"""fig.add_trace(go.Scatter( + x=table[${bandConf.xValue}], + y=table[${bandConf.yUpper}], mode='lines', marker=dict(color="#444"), line=dict(width=0), @@ -93,8 +96,8 @@ class ContinuousErrorBandsOpDesc extends PythonOperatorDescriptor { $namePart )) fig.add_trace(go.Scatter( - x=table['${bandConf.xValue}'], - y=table['${bandConf.yLower}'], + x=table[${bandConf.xValue}], + y=table[${bandConf.yLower}], mode='lines', marker=dict(color="#444"), line=dict(width=0), @@ -104,27 +107,27 @@ class ContinuousErrorBandsOpDesc extends PythonOperatorDescriptor { $namePart )) fig.add_trace(go.Scatter( - x=table['${bandConf.xValue}'], - y=table['${bandConf.yValue}'], - mode='${bandConf.mode.getModeInPlotly}', + x=table[${bandConf.xValue}], + y=table[${bandConf.yValue}], + mode=${bandConf.mode.getModeInPlotly}, $colorPart $namePart ))""" } - s""" + pyb""" | fig = go.Figure() | ${bandsPart.mkString("\n ")} | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0), - | xaxis_title='$xLabel', - | yaxis_title='$yLabel', + | xaxis_title=$xLabel, + | yaxis_title=$yLabel, | hovermode="x") - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -148,7 +151,7 @@ class ContinuousErrorBandsOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/contourPlot/ContourPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/contourPlot/ContourPlotOpDesc.scala index 3c77dd4e044..dd0f41b0faa 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/contourPlot/ContourPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/contourPlot/ContourPlotOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -34,24 +36,24 @@ class ContourPlotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("x") @JsonPropertyDescription("The column name of X-axis") @AutofillAttributeName - var x: String = "" + var x: EncodableString = "" @JsonProperty(value = "y", required = true) @JsonSchemaTitle("y") @JsonPropertyDescription("The column name of Y-axis") @AutofillAttributeName - var y: String = "" + var y: EncodableString = "" @JsonProperty(value = "z", required = true) @JsonSchemaTitle("z") @JsonPropertyDescription("The column name of color bar") @AutofillAttributeName - var z: String = "" + var z: EncodableString = "" @JsonProperty(required = false, defaultValue = "10") @JsonSchemaTitle("Grid Size") @JsonPropertyDescription("Grid resolution of the final image") - var gridSize: String = "" + var gridSize: EncodableString = "" @JsonProperty(required = false, defaultValue = "true") @JsonSchemaTitle("Connect Gaps") @@ -84,7 +86,7 @@ class ContourPlotOpDesc extends PythonOperatorDescriptor { ) override def generatePythonCode(): String = { - s"""from pytexera import * + pyb"""from pytexera import * |import numpy as np |import plotly.graph_objects as go |from scipy.interpolate import griddata @@ -94,11 +96,11 @@ class ContourPlotOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | x = table['$x'].values - | y = table['$y'].values - | z = table['$z'].values - | grid_size = int('$gridSize') - | connGaps = True if '$connectGaps' == 'true' else False + | x = table[$x].values + | y = table[$y].values + | z = table[$z].values + | grid_size = int($gridSize) + | connGaps = True if $connectGaps == 'true' else False | | grid_x, grid_y = np.meshgrid(np.linspace(min(x), max(x), grid_size), np.linspace(min(y), max(y), grid_size)) | grid_z = griddata((x, y), z, (grid_x, grid_y), method='cubic') @@ -108,12 +110,12 @@ class ContourPlotOpDesc extends PythonOperatorDescriptor { | y=np.linspace(min(y), max(y), grid_size), | z=grid_z, | connectgaps=connGaps, - | contours_coloring ='${coloringMethod.getColoringMethod}', - | colorbar_title='$z' + | contours_coloring =${coloringMethod.getColoringMethod}, + | colorbar_title=$z | )) | fig.update_layout(title='Contour Plot') | html = pio.to_html(fig, include_plotlyjs='cdn', full_html=False) | yield {'html-content': html} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dendrogram/DendrogramOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dendrogram/DendrogramOpDesc.scala index 4cb31e76746..d33ff2708f1 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dendrogram/DendrogramOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dendrogram/DendrogramOpDesc.scala @@ -23,34 +23,37 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class DendrogramOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "xVal", required = true) @JsonSchemaTitle("Value X Column") @JsonPropertyDescription("The x values of points in dendrogram") @AutofillAttributeName - var xVal: String = "" + var xVal: EncodableString = "" @JsonProperty(value = "yVal", required = true) @JsonSchemaTitle("Value Y Column") @JsonPropertyDescription("The y value of points in dendrogram") @AutofillAttributeName - var yVal: String = "" + var yVal: EncodableString = "" @JsonProperty(value = "Labels", required = true) @JsonSchemaTitle("Labels") @JsonPropertyDescription("The label of points in dendrogram") @AutofillAttributeName - var labels: String = "" + var labels: EncodableString = "" @JsonProperty(defaultValue = "", required = false) @JsonSchemaTitle("Color Threshold") @JsonPropertyDescription("Value at which separation of clusters will be made") - var threshold: String = "" + var threshold: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -69,28 +72,28 @@ class DendrogramOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - private def createDendrogram(): String = { + private def createDendrogram(): PythonTemplateBuilder = { assert(xVal.nonEmpty) assert(yVal.nonEmpty) assert(labels.nonEmpty) - val strippedThreshold = threshold.trim + val strippedThreshold: EncodableString = threshold.trim val isThreshold = - if (strippedThreshold.nonEmpty) s"color_threshold=$strippedThreshold" + if (strippedThreshold.nonEmpty) pyb"color_threshold=$strippedThreshold" else "color_threshold=None" - s""" - | x = np.array(table["$xVal"]) - | y = np.array(table["$yVal"]) + pyb""" + | x = np.array(table[$xVal]) + | y = np.array(table[$yVal]) | data = np.column_stack((x, y)) - | labels = table["$labels"].tolist() + | labels = table[$labels].tolist() | | fig = ff.create_dendrogram(data, labels=labels, $isThreshold) | fig.update_layout(yaxis_title="Linkage Distance", margin=dict(l=0, r=0, b=0, t=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -115,7 +118,7 @@ class DendrogramOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfig.scala index 13a803debbf..65f56e6fdcd 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfig.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.dumbbellPlot import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import javax.validation.constraints.NotNull @@ -41,6 +42,6 @@ class DumbbellDotConfig { @JsonPropertyDescription("value for dot axis") @AutofillAttributeName @NotNull(message = "Dot Column Value cannot be empty") - var dotValue: String = "" + var dotValue: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellPlotOpDesc.scala index ac49336c0a9..88b6caae614 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import java.util import javax.validation.constraints.{NotBlank, NotNull} @@ -48,33 +51,33 @@ class DumbbellPlotOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("the name of the category column") @AutofillAttributeName @NotNull(message = "Category Column Name cannot be empty") - var categoryColumnName: String = "" + var categoryColumnName: EncodableString = "" @JsonProperty(value = "dumbbellStartValue", required = true) @JsonSchemaTitle("Dumbbell Start Value") @JsonPropertyDescription("the start point value of each dumbbell") @NotBlank(message = "Dumbbell Start Value cannot be empty") - var dumbbellStartValue: String = "" + var dumbbellStartValue: EncodableString = "" @JsonProperty(value = "dumbbellEndValue", required = true) @JsonSchemaTitle("Dumbbell End Value") @JsonPropertyDescription("the end value of each dumbbell") @NotBlank(message = "Dumbbell End Value cannot be empty") - var dumbbellEndValue: String = "" + var dumbbellEndValue: EncodableString = "" @JsonProperty(value = "measurementColumnName", required = true) @JsonSchemaTitle("Measurement Column Name") @JsonPropertyDescription("the name of the measurement column") @AutofillAttributeName @NotNull(message = "Measurement Column Name cannot be empty") - var measurementColumnName: String = "" + var measurementColumnName: EncodableString = "" @JsonProperty(value = "comparedColumnName", required = true) @JsonSchemaTitle("Compared Column Name") @JsonPropertyDescription("the column name that is being compared") @AutofillAttributeName @NotNull(message = "Compared Column Name cannot be empty") - var comparedColumnName: String = "" + var comparedColumnName: EncodableString = "" @JsonProperty(value = "dots", required = false) var dots: util.List[DumbbellDotConfig] = _ @@ -102,57 +105,57 @@ class DumbbellPlotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyDumbbellLineFigure(): String = { - val dumbbellValues = dumbbellStartValue + ", " + dumbbellEndValue + def createPlotlyDumbbellLineFigure(): PythonTemplateBuilder = { + val dumbbellValues = pyb"$dumbbellStartValue, $dumbbellEndValue" var showLegendsOption = "showlegend=False" if (showLegends) { showLegendsOption = "showlegend=True" } - s""" + pyb""" | - | entityNames = list(table['${comparedColumnName}'].unique()) + | entityNames = list(table[${comparedColumnName}].unique()) | entityNames = sorted(entityNames, reverse=True) | categoryValues = [${dumbbellValues}] - | filtered_table = table[(table['${comparedColumnName}'].isin(entityNames)) & - | (table['${categoryColumnName}'].isin(categoryValues))] + | filtered_table = table[(table[${comparedColumnName}].isin(entityNames)) & + | (table[${categoryColumnName}].isin(categoryValues))] | | # Create the dumbbell line using Plotly | fig = go.Figure() | color = 'black' | for entity in entityNames: - | entity_data = filtered_table[filtered_table['${comparedColumnName}'] == entity] - | fig.add_trace(go.Scatter(x=entity_data['${measurementColumnName}'], + | entity_data = filtered_table[filtered_table[${comparedColumnName}] == entity] + | fig.add_trace(go.Scatter(x=entity_data[${measurementColumnName}], | y=[entity]*len(entity_data), | mode='lines', | name=entity, | line=dict(color=color))) | - | fig.update_layout(xaxis_title="${measurementColumnName}", - | yaxis_title="${comparedColumnName}", + | fig.update_layout(xaxis_title=${measurementColumnName}, + | yaxis_title=${comparedColumnName}, | yaxis=dict(categoryorder='array', categoryarray=entityNames), | ${showLegendsOption} | ) - |""".stripMargin + |""" } - def addPlotlyDots(): String = { + def addPlotlyDots(): PythonTemplateBuilder = { var dotColumnNames = "" if (dots != null && dots.size() != 0) { dotColumnNames = dots.asScala .map { dot => - s"'${dot.dotValue}'" + pyb"${dot.dotValue}" } .mkString(",") } - s""" + pyb""" | dotColumnNames = [${dotColumnNames}] | if len(dotColumnNames) > 0: | for dotColumn in dotColumnNames: | # Extract dot data for each entity | for entity in entityNames: - | entity_dot_data = filtered_table[filtered_table['${comparedColumnName}'] == entity] + | entity_dot_data = filtered_table[filtered_table[${comparedColumnName}] == entity] | # Extract X and Y values for the dot | x_values = entity_dot_data[dotColumn].values | y_values = [entity] * len(x_values) @@ -161,11 +164,11 @@ class DumbbellPlotOpDesc extends PythonOperatorDescriptor { | mode='markers', | name=entity + ' ' + dotColumn, | marker=dict(color='black', size=5))) # Customize color and size as needed - |""".stripMargin + |""" } override def generatePythonCode(): String = { - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -191,6 +194,6 @@ class DumbbellPlotOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfig.scala index f8d5f0dd354..726f3d78ea5 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfig.scala @@ -21,11 +21,12 @@ package org.apache.texera.amber.operator.visualization.figureFactoryTable import com.fasterxml.jackson.annotation.JsonProperty import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName class FigureFactoryTableConfig { @JsonProperty(required = true) @JsonSchemaTitle("Attribute Name") @AutofillAttributeName - var attributeName: String = "" + var attributeName: EncodableString = "" } 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 15d79e327da..11168488b74 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 @@ -23,67 +23,70 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class FigureFactoryTableOpDesc extends PythonOperatorDescriptor { @JsonProperty(required = false) @JsonSchemaTitle("Font Size") @JsonPropertyDescription("Font size of the Figure Factory Table") - var fontSize: String = "12" + var fontSize: Double = 12 @JsonProperty(required = false) @JsonSchemaTitle("Font Color (Hex Code)") @JsonPropertyDescription("Font color of the Figure Factory Table") - var fontColor: String = "#000000" + var fontColor: EncodableString = "#000000" @JsonProperty(required = false) @JsonSchemaTitle("Row Height") @JsonPropertyDescription("Row height of the Figure Factory Table") - var rowHeight: String = "30" + var rowHeight: Double = 30 @JsonPropertyDescription("List of columns to include in the figure factory table") @JsonProperty(value = "add attribute", required = true) var columns: List[FigureFactoryTableConfig] = List() private def getAttributes: String = - columns.map(_.attributeName).mkString("'", "','", "'") + columns.map(c => pyb"""${c.attributeName}""").mkString(",") - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(columns.nonEmpty) val attributes = getAttributes - s""" - | # drops rows with missing values pertaining to relevant columns - | table = table.dropna(subset=[$attributes]) - | - |""".stripMargin + pyb""" + | # drops rows with missing values pertaining to relevant columns + | table = table.dropna(subset=[$attributes]) + | + |""" } - def createFigureFactoryTablePlotlyFigure(): String = { + def createFigureFactoryTablePlotlyFigure(): PythonTemplateBuilder = { assert(columns.nonEmpty) - val intFontSize: Option[Double] = fontSize.toDoubleOption - val intRowHeight: Option[Double] = rowHeight.toDoubleOption + val intFontSize: Option[Double] = Option(fontSize) + val intRowHeight: Option[Double] = Option(rowHeight) assert(intFontSize.isDefined && intFontSize.get >= 0) assert(intRowHeight.isDefined && intRowHeight.get >= 30) val attributes = getAttributes - s""" - | filtered_table = table[[$attributes]] - | headers = filtered_table.columns.tolist() - | cell_values = [filtered_table[col].tolist() for col in headers] - | - | data = [headers] + list(map(list, zip(*cell_values))) - | fig = ff.create_table(data, height_constant = ${intRowHeight.get}, font_colors=['$fontColor']) - | - | # Make text size larger - | for i in range(len(fig.layout.annotations)): - | fig.layout.annotations[i].font.size = ${intFontSize.get} - | - |""".stripMargin + pyb""" + | filtered_table = table[[$attributes]] + | headers = filtered_table.columns.tolist() + | cell_values = [filtered_table[col].tolist() for col in headers] + | + | data = [headers] + list(map(list, zip(*cell_values))) + | fig = ff.create_table(data, height_constant = ${intRowHeight.get}, font_colors=[$fontColor]) + | + | # Make text size larger + | for i in range(len(fig.layout.annotations)): + | fig.layout.annotations[i].font.size = ${intFontSize.get} + | + |""" } override def generatePythonCode(): String = { diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/filledAreaPlot/FilledAreaPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/filledAreaPlot/FilledAreaPlotOpDesc.scala index f8e54a93f0e..d8d47696157 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/filledAreaPlot/FilledAreaPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/filledAreaPlot/FilledAreaPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -37,26 +40,26 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The attribute for your x-axis") @AutofillAttributeName @NotNull(message = "X-axis Attribute cannot be empty") - var x: String = "" + var x: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Y-axis Attribute") @JsonPropertyDescription("The attribute for your y-axis") @AutofillAttributeName @NotNull(message = "Y-axis Attribute cannot be empty") - var y: String = "" + var y: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Line Group") @JsonPropertyDescription("The attribute for group of each line") @AutofillAttributeName - var lineGroup: String = "" + var lineGroup: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Color") @JsonPropertyDescription("Choose an attribute to color the plot") @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Split Plot by Line Group") @@ -67,7 +70,7 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Pattern") @JsonPropertyDescription("Add texture to the chart based on an attribute") @AutofillAttributeName - var pattern: String = "" + var pattern: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -87,7 +90,7 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(x.nonEmpty) assert(y.nonEmpty) @@ -95,24 +98,24 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { assert(lineGroup.nonEmpty) } - val colorArg = if (color.nonEmpty) s""", color="$color"""" else "" - val facetColumnArg = if (facetColumn) s""", facet_col="$lineGroup"""" else "" - val lineGroupArg = if (lineGroup.nonEmpty) s""", line_group="$lineGroup"""" else "" - val patternParam = if (pattern.nonEmpty) s""", pattern_shape="$pattern"""" else "" + val colorArg = if (color.nonEmpty) pyb""", color=$color""" else "" + val facetColumnArg = if (facetColumn) pyb""", facet_col=$lineGroup""" else "" + val lineGroupArg = if (lineGroup.nonEmpty) pyb""", line_group=$lineGroup""" else "" + val patternParam = if (pattern.nonEmpty) pyb""", pattern_shape=$pattern""" else "" - s""" - | fig = px.area(table, x="$x", y="$y"$colorArg$facetColumnArg$lineGroupArg$patternParam) - |""".stripMargin + pyb""" + | fig = px.area(table, x=$x, y=$y$colorArg$facetColumnArg$lineGroupArg$patternParam) + |""" } // The function below checks whether there are more than 5 percents of the groups have disjoint sets of x attributes. - def performTableCheck(): String = { - s""" + def performTableCheck(): PythonTemplateBuilder = { + pyb""" | error = "" - | if "$x" not in columns or "$y" not in columns: + | if $x not in columns or $y not in columns: | error = "missing attributes" - | elif "$lineGroup" != "": - | grouped = table.groupby("$lineGroup") + | elif $lineGroup != "": + | grouped = table.groupby($lineGroup) | x_values = None | | tolerance = (len(grouped) // 100) * 5 @@ -120,19 +123,19 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { | | for _, group in grouped: | if x_values == None: - | x_values = set(group["$x"].unique()) - | elif set(group["$x"].unique()).intersection(x_values): - | X_values = x_values.union(set(group["$x"].unique())) - | elif not set(group["$x"].unique()).intersection(x_values): + | x_values = set(group[$x].unique()) + | elif set(group[$x].unique()).intersection(x_values): + | X_values = x_values.union(set(group[$x].unique())) + | elif not set(group[$x].unique()).intersection(x_values): | count += 1 | if count > tolerance: | error = "X attributes not shared across groups" - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly @@ -167,8 +170,8 @@ class FilledAreaPlotOpDesc extends PythonOperatorDescriptor { | ''' | | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/funnelPlot/FunnelPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/funnelPlot/FunnelPlotOpDesc.scala index 61ff4ddd393..89c1cb0b104 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/funnelPlot/FunnelPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/funnelPlot/FunnelPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonSchemaInject(json = """ { "attributeTypeRules": { @@ -40,19 +43,19 @@ class FunnelPlotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("X Column") @JsonPropertyDescription("Data column for the x-axis") @AutofillAttributeName - var x: String = "" + var x: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Y Column") @JsonPropertyDescription("Data column for the y-axis") @AutofillAttributeName - var y: String = "" + var y: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Color Column") @JsonPropertyDescription("Column to categorically colorize funnel sections") @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -72,25 +75,25 @@ class FunnelPlotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - private def createPlotlyFigure(): String = { + private def createPlotlyFigure(): PythonTemplateBuilder = { assert(x.nonEmpty) assert(y.nonEmpty) - val colorArg = if (color.nonEmpty) s""", color="$color"""" else "" - s""" - | fig = go.Figure(px.funnel(table, x ="$x", y = "$y"$colorArg)) - | fig.update_layout( - | scene=dict( - | xaxis_title='X: $x', - | yaxis_title='Y: $y', - | ), - | margin=dict(t=0, b=0, l=0, r=0) - | ) - |""".stripMargin + val colorArg = if (color.nonEmpty) pyb""", color=$color""" else "" + pyb""" + | fig = go.Figure(px.funnel(table, x =$x, y = $y$colorArg)) + | fig.update_layout( + | scene=dict( + | xaxis_title='X: ' + $x, + | yaxis_title='Y: ' + $y, + | ), + | margin=dict(t=0, b=0, l=0, r=0) + | ) + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -115,8 +118,7 @@ class FunnelPlotOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDesc.scala index 3be1ae9fa55..9a1f32e43ef 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -49,33 +52,33 @@ class GanttChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("the start timestamp of the task") @AutofillAttributeName @NotNull(message = "Start Datetime Column cannot be empty") - var start: String = "" + var start: EncodableString = "" @JsonProperty(value = "finish", required = true) @JsonSchemaTitle("Finish Datetime Column") @JsonPropertyDescription("the end timestamp of the task") @AutofillAttributeName @NotNull(message = "Finish Datetime Column cannot be empty") - var finish: String = "" + var finish: EncodableString = "" @JsonProperty(value = "task", required = true) @JsonSchemaTitle("Task Column") @JsonPropertyDescription("the name of the task") @AutofillAttributeName @NotNull(message = "Task Column cannot be empty") - var task: String = "" + var task: EncodableString = "" @JsonProperty(value = "color", required = false) @JsonSchemaTitle("Color Column") @JsonPropertyDescription("column to color tasks") @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Pattern") @JsonPropertyDescription("Add texture to the chart based on an attribute") @AutofillAttributeName - var pattern: String = "" + var pattern: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -95,28 +98,28 @@ class GanttChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { - val optionalFilterTable = if (color.nonEmpty) s"&(table['$color'].notnull())" else "" - s""" - | table = table[(table["$start"].notnull())&(table["$finish"].notnull())&(table["$finish"].notnull())$optionalFilterTable].copy() - |""".stripMargin + def manipulateTable(): PythonTemplateBuilder = { + val optionalFilterTable = if (color.nonEmpty) pyb"&(table[$color].notnull())" else "" + pyb""" + | table = table[(table[$start].notnull())&(table[$finish].notnull())&(table[$finish].notnull())$optionalFilterTable].copy() + |""" } - def createPlotlyFigure(): String = { - val colorSetting = if (color.nonEmpty) s", color='$color'" else "" - val patternParam = if (pattern.nonEmpty) s", pattern_shape='$pattern'" else "" + def createPlotlyFigure(): PythonTemplateBuilder = { + val colorSetting = if (color.nonEmpty) pyb", color=$color" else pyb"" + val patternParam = if (pattern.nonEmpty) pyb", pattern_shape=$pattern" else pyb"" - s""" - | fig = px.timeline(table, x_start='$start', x_end='$finish', y='$task' $colorSetting $patternParam) + pyb""" + | fig = px.timeline(table, x_start=$start, x_end=$finish, y=$task $colorSetting $patternParam) | fig.update_yaxes(autorange='reversed') | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -143,7 +146,7 @@ class GanttChartOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartOpDesc.scala index fdc48ccc2a3..c890786eeff 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartOpDesc.scala @@ -24,6 +24,8 @@ import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -34,17 +36,17 @@ class GaugeChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Gauge Value") @JsonPropertyDescription("The primary value displayed on the gauge chart") @AutofillAttributeName - var value: String = "" + var value: EncodableString = "" @JsonProperty(value = "delta", required = false) @JsonSchemaTitle("Delta") @JsonPropertyDescription("The baseline value used to calculate the delta from the gauge value") - var delta: String = "" + var delta: EncodableString = "" @JsonProperty(value = "threshold", required = false) @JsonSchemaTitle("Threshold Value") @JsonPropertyDescription("Defines a boundary or target value shown on the gauge chart") - var threshold: String = "" + var threshold: EncodableString = "" @JsonProperty(value = "steps", required = false) @JsonSchemaTitle("Steps") @@ -75,9 +77,9 @@ class GaugeChartOpDesc extends PythonOperatorDescriptor { } override def generatePythonCode(): String = { - val stepsStr: String = serializeSteps(steps) + val stepsStr: EncodableString = serializeSteps(steps) - s""" + pyb""" |from pytexera import * |import plotly.graph_objects as go |import plotly.io as pio @@ -103,13 +105,13 @@ class GaugeChartOpDesc extends PythonOperatorDescriptor { | return | | try: - | gauge_value = "$value" + | gauge_value = $value | try: - | delta_ref = float("$delta") if "$delta".strip() else None + | delta_ref = float($delta) if $delta.strip() else None | except ValueError: | delta_ref = None | try: - | threshold_val = float("$threshold") if "$threshold".strip() else None + | threshold_val = float($threshold) if $threshold.strip() else None | except ValueError: | threshold_val = None | @@ -119,7 +121,7 @@ class GaugeChartOpDesc extends PythonOperatorDescriptor { | return | | try: - | valid_steps = json.loads('''$stepsStr''') + | valid_steps = json.loads($stepsStr) | step_colors = self.generate_gray_gradient(len(valid_steps)) | steps_list = [] | for index, step_data in enumerate(valid_steps): @@ -184,6 +186,6 @@ class GaugeChartOpDesc extends PythonOperatorDescriptor { | | except Exception as e: | yield {'html-content': self.render_error(f"General error: {str(e)}")} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartSteps.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartSteps.scala index 78f407af37e..4c6235a9ad9 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartSteps.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/gaugeChart/GaugeChartSteps.scala @@ -20,13 +20,14 @@ package org.apache.texera.amber.operator.visualization.gaugeChart import com.fasterxml.jackson.annotation.JsonProperty import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString class GaugeChartSteps { @JsonProperty("start") @JsonSchemaTitle("Start") - var start: String = "" + var start: EncodableString = "" @JsonProperty("end") @JsonSchemaTitle("End") - var end: String = "" + var end: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDesc.scala index f66730090e7..d38dfdf4c90 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDesc.scala @@ -23,29 +23,32 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class HeatMapOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "x", required = true) @JsonSchemaTitle("Value X Column") @JsonPropertyDescription("the values along the x-axis") @AutofillAttributeName - var x: String = "" + var x: EncodableString = "" @JsonProperty(value = "y", required = true) @JsonSchemaTitle("Value Y Column") @JsonPropertyDescription("the values along the y-axis") @AutofillAttributeName - var y: String = "" + var y: EncodableString = "" @JsonProperty(value = "Values", required = true) @JsonSchemaTitle("Values") @JsonPropertyDescription("the values of the heatmap") @AutofillAttributeName - var value: String = "" + var value: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -65,20 +68,20 @@ class HeatMapOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - private def createHeatMap(): String = { + private def createHeatMap(): PythonTemplateBuilder = { assert(x.nonEmpty) assert(y.nonEmpty) assert(value.nonEmpty) - s""" - | heatmap = go.Heatmap(z=table["$value"],x=table["$x"],y=table["$y"]) + pyb""" + | heatmap = go.Heatmap(z=table[$value],x=table[$x],y=table[$y]) | layout = go.Layout(margin=dict(l=0, r=0, b=0, t=0)) | fig = go.Figure(data=[heatmap], layout=layout) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -103,8 +106,8 @@ class HeatMapOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDesc.scala index 5b42935307c..d46549111c2 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.{NotEmpty, NotNull} @@ -60,7 +63,7 @@ class HierarchyChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The value associated with the size of each sector in the chart") @AutofillAttributeName @NotNull(message = "Value column cannot be empty") - var value: String = "" + var value: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -81,30 +84,30 @@ class HierarchyChartOpDesc extends PythonOperatorDescriptor { ) private def getHierarchyAttributesInPython: String = - hierarchy.map(_.attributeName).mkString("'", "','", "'") + hierarchy.map(c => pyb"${c.attributeName}").mkString(",") - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(value.nonEmpty) val attributes = getHierarchyAttributesInPython - s""" - | table['$value'] = table[table['$value'] > 0]['$value'] # remove non-positive numbers from the data + pyb""" + | table[$value] = table[table[$value] > 0][$value] # remove non-positive numbers from the data | table.dropna(subset = [$attributes], inplace = True) #remove missing values - |""".stripMargin + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(hierarchy.nonEmpty) val attributes = getHierarchyAttributesInPython - s""" - | fig = px.${hierarchyChartType.getPlotlyExpressApiName}(table, path=[$attributes], values='$value', - | color='$value', hover_data=[$attributes], + pyb""" + | fig = px.${hierarchyChartType.getPlotlyExpressApiName}(table, path=[$attributes], values=$value, + | color=$value, hover_data=[$attributes], | color_continuous_scale='RdBu') - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -134,8 +137,8 @@ class HierarchyChartOpDesc extends PythonOperatorDescriptor { | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySection.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySection.scala index 9d8946f7b1d..55128f5ee2a 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySection.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySection.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.hierarchychart import com.fasterxml.jackson.annotation.JsonProperty import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import javax.validation.constraints.NotNull @@ -32,5 +33,5 @@ class HierarchySection { @JsonSchemaTitle("Attribute Name") @AutofillAttributeName @NotNull(message = "Attribute Name cannot be empty") - var attributeName: String = "" + var attributeName: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram/HistogramChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram/HistogramChartOpDesc.scala index 8e3ac2c3793..0c1a29b781b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram/HistogramChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram/HistogramChartOpDesc.scala @@ -23,39 +23,42 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class HistogramChartOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "value", required = true) @JsonSchemaTitle("Value Column") @JsonPropertyDescription("Column for counting values.") @AutofillAttributeName - var value: String = "" + var value: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Color Column") @JsonPropertyDescription("Column for differentiating data by its value.") @AutofillAttributeName - var color: String = "" + var color: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("SeparateBy Column") @JsonPropertyDescription("Column for separating histogram chart by its value.") @AutofillAttributeName - var separateBy: String = "" + var separateBy: EncodableString = "" @JsonProperty(required = false, defaultValue = "") @JsonSchemaTitle("Distribution Type") @JsonPropertyDescription("Distribution type (rug, box, violin).") - var marginal: String = "" + var marginal: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Pattern") @JsonPropertyDescription("Add texture to the chart based on an attribute") @AutofillAttributeName - var pattern: String = "" + var pattern: EncodableString = "" override def operatorInfo: OperatorInfo = OperatorInfo( @@ -66,26 +69,26 @@ class HistogramChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(value.nonEmpty) - var colorParam = "" - var categoryParam = "" - var marginalParam = "" - var patternParam = "" - if (color.nonEmpty) colorParam = s", color = '$color'" - if (separateBy.nonEmpty) categoryParam = s", facet_col = '$separateBy'" - if (marginal.nonEmpty) marginalParam = s", marginal='$marginal'" - if (pattern != "") patternParam = s", pattern_shape='$pattern'" + var colorParam = pyb"" + var categoryParam = pyb"" + var marginalParam = pyb"" + var patternParam = pyb"" + if (color.nonEmpty) colorParam = pyb", color = $color" + if (separateBy.nonEmpty) categoryParam = pyb", facet_col = $separateBy" + if (marginal.nonEmpty) marginalParam = pyb", marginal=$marginal" + if (pattern != "") patternParam = pyb", pattern_shape=$pattern" - s""" - | fig = px.histogram(table, x = '$value', text_auto = True $colorParam $categoryParam $marginalParam $patternParam) - | fig.update_layout(margin=dict(l=0, r=0, t=0, b=0)) - |""".stripMargin + pyb""" + | fig = px.histogram(table, x = $value, text_auto = True $colorParam $categoryParam $marginalParam $patternParam) + | fig.update_layout(margin=dict(l=0, r=0, t=0, b=0)) + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -109,8 +112,8 @@ class HistogramChartOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalCode + |""" + finalCode.encode } override def getOutputSchemas( diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDesc.scala index 1068b5e8fa8..88167a8353f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDesc.scala @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -33,23 +35,23 @@ class Histogram2DOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("X Column") @JsonPropertyDescription("Numeric column for the X axis bins.") @AutofillAttributeName - var xColumn = "" + var xColumn: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Y Column") @JsonPropertyDescription("Numeric column for the Y axis bins.") @AutofillAttributeName - var yColumn = "" + var yColumn: EncodableString = "" @JsonProperty(required = true, defaultValue = "10") @JsonSchemaTitle("X Bins") @JsonPropertyDescription("Number of bins along the X axis (Default: 10)") - var xBins: Int = _ + var xBins: Int = 10 @JsonProperty(required = true, defaultValue = "10") @JsonSchemaTitle("Y Bins") @JsonPropertyDescription("Number of bins along the Y axis (Default: 10)") - var yBins: Int = _ + var yBins: Int = 10 @JsonProperty(required = false, defaultValue = "density") @JsonSchemaTitle("Normalization") @@ -79,9 +81,9 @@ class Histogram2DOpDesc extends PythonOperatorDescriptor { assert(yBins > 0, s"Y Bins must be > 0, but got $yBins") val normArg = - s"histnorm='${normalize.getValue}'," + pyb"histnorm=${normalize.getValue}," - s""" + pyb""" |from pytexera import * |import plotly.express as px |import plotly.io @@ -98,23 +100,23 @@ class Histogram2DOpDesc extends PythonOperatorDescriptor { | return | | # Drop rows with missing x/y - | table.dropna(subset=['${xColumn}', '${yColumn}'], inplace=True) + | table.dropna(subset=[$xColumn, $yColumn], inplace=True) | if table.empty: | yield {"html-content": self.render_error("No rows after dropping nulls.")} | return | | fig = px.density_heatmap( | table, - | x='${xColumn}', - | y='${yColumn}', - | nbinsx=${xBins}, - | nbinsy=${yBins}, - | ${normArg} + | x=$xColumn, + | y=$yColumn, + | nbinsx=$xBins, + | nbinsy=$yBins, + | $normArg | text_auto=True | ) | | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {"html-content": html} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDesc.scala index 90613e855e4..2400b53e11b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDesc.scala @@ -23,9 +23,12 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import java.util import scala.jdk.CollectionConverters.ListHasAsScala @@ -35,12 +38,12 @@ class LineChartOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "yLabel", required = false, defaultValue = "Y Axis") @JsonSchemaTitle("Y Label") @JsonPropertyDescription("the label for y axis") - var yLabel: String = "" + var yLabel: EncodableString = "" @JsonProperty(value = "xLabel", required = false, defaultValue = "X Axis") @JsonSchemaTitle("X Label") @JsonPropertyDescription("the label for x axis") - var xLabel: String = "" + var xLabel: EncodableString = "" @JsonProperty(value = "lines", required = true) var lines: util.List[LineConfig] = _ @@ -63,42 +66,42 @@ class LineChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { val linesPart = lines.asScala .map { lineConf => val colorPart = if (lineConf.color != "") { - s"line={'color':'${lineConf.color}'}, marker={'color':'${lineConf.color}'}, " + pyb"line={'color':${lineConf.color}}, marker={'color':${lineConf.color}}, " } else { - "" + pyb"" } val namePart = if (lineConf.name != "") { - s"name='${lineConf.name}'" + pyb"name=${lineConf.name}" } else { - s"name='${lineConf.yValue}'" + pyb"name=${lineConf.yValue}" } - s"""fig.add_trace(go.Scatter( - x=table['${lineConf.xValue}'], - y=table['${lineConf.yValue}'], + pyb"""fig.add_trace(go.Scatter( + x=table[${lineConf.xValue}], + y=table[${lineConf.yValue}], mode='${lineConf.mode.getModeInPlotly}', $colorPart $namePart ))""" } - s""" + pyb""" | fig = go.Figure() | ${linesPart.mkString("\n ")} | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0), - | xaxis_title='$xLabel', - | yaxis_title='$yLabel') - |""".stripMargin + | xaxis_title=$xLabel, + | yaxis_title=$yLabel) + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -123,8 +126,8 @@ class LineChartOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineConfig.scala index 46eb19004d2..1a6378be737 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/lineChart/LineConfig.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.lineChart import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import javax.validation.constraints.NotNull @@ -45,14 +46,14 @@ class LineConfig { @JsonPropertyDescription("value for y axis") @AutofillAttributeName @NotNull(message = "Y Value cannot be empty") - var yValue: String = "" + var yValue: EncodableString = "" @JsonProperty(value = "x", required = true) @JsonSchemaTitle("X Value") @JsonPropertyDescription("value for x axis") @AutofillAttributeName @NotNull(message = "X Value cannot be empty") - var xValue: String = "" + var xValue: EncodableString = "" @JsonProperty( value = "mode", @@ -65,11 +66,11 @@ class LineConfig { @JsonProperty(value = "name", required = false) @JsonSchemaTitle("Line Name") - var name: String = "" + var name: EncodableString = "" @JsonProperty(value = "color", required = false) @JsonSchemaTitle("Line Color") @JsonPropertyDescription("must be a valid CSS color or hex color string") - var color: String = "" + var color: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfig.scala index 832346a3790..31d0e22ae5b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfig.scala @@ -20,19 +20,20 @@ package org.apache.texera.amber.operator.visualization.nestedTable import com.fasterxml.jackson.annotation.JsonProperty import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName class NestedTableConfig { @JsonProperty(required = true) @JsonSchemaTitle("Attribute group") - var attributeGroup: String = "" + var attributeGroup: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Original attribute Name") @AutofillAttributeName - var originalName: String = "" + var originalName: EncodableString = "" @JsonProperty(value = "name", required = false) @JsonSchemaTitle("New Attribute Name") - var newName: String = "" + var newName: EncodableString = "" } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala index f27face37d3..aaaf4cdc95b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableOpDesc.scala @@ -21,9 +21,11 @@ package org.apache.texera.amber.operator.visualization.nestedTable import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import java.util import scala.jdk.CollectionConverters.ListHasAsScala @@ -53,17 +55,17 @@ class NestedTableOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - private def createNestedTable(): String = { + private def createNestedTable(): PythonTemplateBuilder = { val sortedColumns = includedColumns.asScala.sortBy(_.attributeGroup) - s""" + pyb""" | columns = pd.MultiIndex.from_tuples([ | ${sortedColumns .map { config => val name = if (config.newName != null && config.newName.nonEmpty) config.newName else config.originalName - s"('${config.attributeGroup}', '${name}')" + pyb"(${config.attributeGroup}, $name)" } .mkString(",\n ")} | ]) @@ -72,7 +74,7 @@ class NestedTableOpDesc extends PythonOperatorDescriptor { | for _, row in table.iterrows(): | data.append([ | ${sortedColumns - .map(config => s"row['${config.originalName}']") + .map(config => pyb"row[${config.originalName}]") .mkString(", ")} | ]) | @@ -105,12 +107,12 @@ class NestedTableOpDesc extends PythonOperatorDescriptor { | .set_table_attributes('class="dataframe"') | .hide(axis="index") | ) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import pandas as pd @@ -132,7 +134,7 @@ class NestedTableOpDesc extends PythonOperatorDescriptor { | html = styled_table.to_html() | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala index 16537e5d944..58ae0c00cbc 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala @@ -23,27 +23,30 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class NetworkGraphOpDesc extends PythonOperatorDescriptor { @JsonProperty(required = true) @JsonSchemaTitle("Source Column") @JsonPropertyDescription("Source node for edge in graph") @AutofillAttributeName - var source: String = "" + var source: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Destination Column") @JsonPropertyDescription("Destination node for edge in graph") @AutofillAttributeName - var destination: String = "" + var destination: EncodableString = "" @JsonProperty(defaultValue = "Network Graph") @JsonSchemaTitle("Title") - var title: String = "" + var title: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -62,18 +65,19 @@ class NetworkGraphOpDesc extends PythonOperatorDescriptor { inputPorts = List(InputPort()), outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + + def manipulateTable(): PythonTemplateBuilder = { assert(source.nonEmpty) assert(destination.nonEmpty) - s""" - | table = table.dropna(subset = ['$source']) #remove missing values - | table = table.dropna(subset = ['$destination']) #remove missing values - |""".stripMargin + pyb""" + | table = table.dropna(subset = [$source]) #remove missing values + | table = table.dropna(subset = [$destination]) #remove missing values + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * |import pandas as pd |import plotly.graph_objects as go @@ -92,14 +96,14 @@ class NetworkGraphOpDesc extends PythonOperatorDescriptor { | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: | if not table.empty: - | sources = table['$source'] - | destinations = table['$destination'] + | sources = table[$source] + | destinations = table[$destination] | nodes = set(sources + destinations) | G = nx.Graph() | for node in nodes: | G.add_node(node) | for i, j in table.iterrows(): - | G.add_edges_from([(j['$source'], j['$destination'])]) + | G.add_edges_from([(j[$source], j[$destination])]) | pos = nx.spring_layout(G, k=0.5, iterations=50) | for n, p in pos.items(): | G.nodes[n]['pos'] = p @@ -157,7 +161,7 @@ class NetworkGraphOpDesc extends PythonOperatorDescriptor { | fig = go.Figure( | data=[edge_trace, node_trace], | layout=go.Layout( - | title='
$title', + | title='
'+$title, | hovermode='closest', | showlegend=False, | margin=dict(b=20, l=5, r=5, t=40), @@ -187,8 +191,8 @@ class NetworkGraphOpDesc extends PythonOperatorDescriptor { | | yield {'html-content': html} | - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDesc.scala index 444987eebf6..75e532e2d89 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -47,14 +50,14 @@ class PieChartOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The value associated with slice of pie") @AutofillAttributeName @NotNull(message = "Value column cannot be empty") - var value: String = "" + var value: EncodableString = "" @JsonProperty(value = "name", required = true) @JsonSchemaTitle("Name Column") @JsonPropertyDescription("The name of the slice of pie") @AutofillAttributeName @NotNull(message = "Name column cannot be empty") - var name: String = "" + var name: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -74,25 +77,25 @@ class PieChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(value.nonEmpty) - s""" - | table.dropna(subset = ['$value', '$name'], inplace = True) #remove missing values - |""".stripMargin + pyb""" + | table.dropna(subset = [$value, $name], inplace = True) #remove missing values + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(value.nonEmpty) - s""" - | fig = px.pie(table, names='$name', values='$value') + pyb""" + | fig = px.pie(table, names=$name, values=$value) | fig.update_traces(textposition='inside', textinfo='percent+label') | fig.update_layout(margin=dict(t=0, b=0, l=0, r=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -116,7 +119,7 @@ class PieChartOpDesc extends PythonOperatorDescriptor { | if table.empty: | yield {'html-content': self.render_error("value column contains only non-positive numbers.")} | return - | duplicates = table.duplicated(subset=['$name']) + | duplicates = table.duplicated(subset=[$name]) | if duplicates.any(): | yield {'html-content': self.render_error("duplicates in name column, need to aggregate")} | return @@ -125,8 +128,8 @@ class PieChartOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDesc.scala index 4eaf9a35ca6..8246131fc2d 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonSchemaInject(json = """ { @@ -44,22 +47,22 @@ class QuiverPlotOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "x", required = true) @JsonSchemaTitle("x") @JsonPropertyDescription("Column for the x-coordinate of the starting point") - @AutofillAttributeName var x: String = "" + @AutofillAttributeName var x: EncodableString = "" @JsonProperty(value = "y", required = true) @JsonSchemaTitle("y") @JsonPropertyDescription("Column for the y-coordinate of the starting point") - @AutofillAttributeName var y: String = "" + @AutofillAttributeName var y: EncodableString = "" @JsonProperty(value = "u", required = true) @JsonSchemaTitle("u") @JsonPropertyDescription("Column for the vector component in the x-direction") - @AutofillAttributeName var u: String = "" + @AutofillAttributeName var u: EncodableString = "" @JsonProperty(value = "v", required = true) @JsonSchemaTitle("v") @JsonPropertyDescription("Column for the vector component in the y-direction") - @AutofillAttributeName var v: String = "" + @AutofillAttributeName var v: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -80,15 +83,15 @@ class QuiverPlotOpDesc extends PythonOperatorDescriptor { ) //data cleaning for missing value - def manipulateTable(): String = { - s""" + def manipulateTable(): PythonTemplateBuilder = { + pyb""" | table = table.dropna() #remove missing values - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * |import pandas as pd |import plotly.figure_factory as ff @@ -109,7 +112,7 @@ class QuiverPlotOpDesc extends PythonOperatorDescriptor { | yield {'html-content': self.render_error("Input table is empty.")} | return | - | required_columns = {'${x}', '${y}', '${u}', '${v}'} + | required_columns = {$x, $y, $u, $v} | if not required_columns.issubset(table.columns): | yield {'html-content': self.render_error(f"Input table must contain columns: {', '.join(required_columns)}")} | return @@ -126,8 +129,8 @@ class QuiverPlotOpDesc extends PythonOperatorDescriptor { | try: | #graph the quiver plot | fig = ff.create_quiver( - | table['${x}'], table['${y}'], - | table['${u}'], table['${v}'], + | table[$x], table[$y], + | table[$u], table[$v], | scale=0.1 | ) | html = fig.to_html(include_plotlyjs='cdn', full_html=False) @@ -137,8 +140,8 @@ class QuiverPlotOpDesc extends PythonOperatorDescriptor { | | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala index b0691ccdbd6..2a13db3e035 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/rangeSlider/RangeSliderOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -46,14 +49,14 @@ class RangeSliderOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The name of the column to represent y-axis") @AutofillAttributeName @NotNull(message = "Y-axis cannot be empty") - var yAxis: String = "" + var yAxis: EncodableString = "" @JsonProperty(value = "X-axis", required = true) @JsonSchemaTitle("X-axis") @JsonPropertyDescription("The name of the column to represent the x-axis") @AutofillAttributeName @NotNull(message = "X-axis cannot be empty") - var xAxis: String = "" + var xAxis: EncodableString = "" @JsonProperty(value = "Duplicates", required = false) @JsonSchemaTitle("Handle Duplicates") @@ -77,40 +80,40 @@ class RangeSliderOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { - s""" - | table = table.dropna(subset=['$xAxis', '$yAxis']) + def manipulateTable(): PythonTemplateBuilder = { + pyb""" + | table = table.dropna(subset=[$xAxis, $yAxis]) | functionType = '${duplicateType.getFunctionType}' | if functionType.lower() == "mean": - | table = table.groupby('$xAxis')['$yAxis'].mean().reset_index() #get mean of values + | table = table.groupby($xAxis)[$yAxis].mean().reset_index() #get mean of values | elif functionType.lower() == "sum": - | table = table.groupby('$xAxis')['$yAxis'].sum().reset_index() #get sum of values - |""".stripMargin + | table = table.groupby($xAxis)[$yAxis].sum().reset_index() #get sum of values + |""" } - def createPlotlyFigure(): String = { - s""" + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" | # Create figure | fig = go.Figure() | - | fig.add_trace(go.Scatter(x=table['$xAxis'], y=table['$yAxis'], mode = "markers+lines")) + | fig.add_trace(go.Scatter(x=table[$xAxis], y=table[$yAxis], mode = "markers+lines")) | | # Add range slider | fig.update_layout( - | xaxis_title='$xAxis', - | yaxis_title='$yAxis', + | xaxis_title=$xAxis, + | yaxis_title=$yAxis, | xaxis=dict( | rangeslider=dict( | visible=True | ) | ) | ) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -130,7 +133,7 @@ class RangeSliderOpDesc extends PythonOperatorDescriptor { | if table.empty: | yield {'html-content': self.render_error("input table is empty.")} | return - | if '$yAxis'.strip() == "" or '$xAxis'.strip() == "": + | if $yAxis.strip() == "" or $xAxis.strip() == "": | yield {'html-content': self.render_error("Y-axis or X-axis is empty")} | return | ${manipulateTable()} @@ -138,8 +141,8 @@ class RangeSliderOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/sankeyDiagram/SankeyDiagramOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/sankeyDiagram/SankeyDiagramOpDesc.scala index 76d089d345f..0261baf741c 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/sankeyDiagram/SankeyDiagramOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/sankeyDiagram/SankeyDiagramOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -37,21 +40,21 @@ class SankeyDiagramOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("The source node of the Sankey diagram") @AutofillAttributeName @NotNull(message = "Source Attribute cannot be empty") - var sourceAttribute: String = "" + var sourceAttribute: EncodableString = "" @JsonProperty(value = "Target Attribute", required = true) @JsonSchemaTitle("Target Attribute") @JsonPropertyDescription("The target node of the Sankey diagram") @AutofillAttributeName @NotNull(message = "Target Attribute cannot be empty") - var targetAttribute: String = "" + var targetAttribute: EncodableString = "" @JsonProperty(value = "Value Attribute", required = true) @JsonSchemaTitle("Value Attribute") @JsonPropertyDescription("The value/volume of the flow between source and target") @AutofillAttributeName @NotNull(message = "Value Attribute cannot be empty") - var valueAttribute: String = "" + var valueAttribute: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -71,41 +74,41 @@ class SankeyDiagramOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { - s""" - | # Grouping source, target, and summing value for the Sankey diagram - | table = table.groupby(['$sourceAttribute', '$targetAttribute'])['$valueAttribute'].sum().reset_index(name='value') - | - | # Create a list of unique labels from both source and target - | labels = pd.concat([table['$sourceAttribute'], table['$targetAttribute']]).unique().tolist() - | - | # Create indices for source and target from the label list - | table['source_index'] = table['$sourceAttribute'].apply(lambda x: labels.index(x)) - | table['target_index'] = table['$targetAttribute'].apply(lambda x: labels.index(x)) - | - | # Create the Sankey diagram - | fig = go.Figure(data=[go.Sankey( - | node=dict( - | pad=15, - | thickness=20, - | line=dict(color="black", width=0.5), - | label=labels, - | color="blue" - | ), - | link=dict( - | source=table['source_index'].tolist(), - | target=table['target_index'].tolist(), - | value=table['value'].tolist() - | ) - | )]) - | - | fig.update_layout(title_text="Sankey Diagram", font_size=10) - |""".stripMargin + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" + | # Grouping source, target, and summing value for the Sankey diagram + | table = table.groupby([$sourceAttribute, $targetAttribute])[$valueAttribute].sum().reset_index(name='value') + | + | # Create a list of unique labels from both source and target + | labels = pd.concat([table[$sourceAttribute], table[$targetAttribute]]).unique().tolist() + | + | # Create indices for source and target from the label list + | table['source_index'] = table[$sourceAttribute].apply(lambda x: labels.index(x)) + | table['target_index'] = table[$targetAttribute].apply(lambda x: labels.index(x)) + | + | # Create the Sankey diagram + | fig = go.Figure(data=[go.Sankey( + | node=dict( + | pad=15, + | thickness=20, + | line=dict(color="black", width=0.5), + | label=labels, + | color="blue" + | ), + | link=dict( + | source=table['source_index'].tolist(), + | target=table['target_index'].tolist(), + | value=table['value'].tolist() + | ) + | )]) + | + | fig.update_layout(title_text="Sankey Diagram", font_size=10) + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * |import plotly.graph_objects as go |import plotly.io @@ -130,7 +133,7 @@ class SankeyDiagramOpDesc extends PythonOperatorDescriptor { | # convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatter3DChart/Scatter3dChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatter3DChart/Scatter3dChartOpDesc.scala index f81e2b654a8..e20ad4a8d1e 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatter3DChart/Scatter3dChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatter3DChart/Scatter3dChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder @JsonSchemaInject(json = """ { "attributeTypeRules": { @@ -39,19 +42,19 @@ class Scatter3dChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("X Column") @JsonPropertyDescription("Data column for the x-axis") @AutofillAttributeName - var x: String = "" + var x: EncodableString = "" @JsonProperty(value = "y", required = true) @JsonSchemaTitle("Y Column") @JsonPropertyDescription("Data column for the y-axis") @AutofillAttributeName - var y: String = "" + var y: EncodableString = "" @JsonProperty(value = "z", required = true) @JsonSchemaTitle("Z Column") @JsonPropertyDescription("Data column for the z-axis") @AutofillAttributeName - var z: String = "" + var z: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -71,37 +74,37 @@ class Scatter3dChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - private def createPlotlyFigure(): String = { + private def createPlotlyFigure(): PythonTemplateBuilder = { assert(x.nonEmpty) assert(y.nonEmpty) assert(z.nonEmpty) - s""" - | fig = go.Figure(data=[go.Scatter3d( - | x=table["$x"], - | y=table["$y"], - | z=table["$z"], - | mode='markers', - | marker=dict( - | size=12, - | colorscale='Viridis', - | opacity=0.8 - | ) - | )]) - | fig.update_traces(marker=dict(size=5, opacity=0.8)) - | fig.update_layout( - | scene=dict( - | xaxis_title='X: $x', - | yaxis_title='Y: $y', - | zaxis_title='Z: $z' - | ), - | margin=dict(t=0, b=0, l=0, r=0) - | ) - |""".stripMargin + pyb""" + | fig = go.Figure(data=[go.Scatter3d( + | x=table[$x], + | y=table[$y], + | z=table[$z], + | mode='markers', + | marker=dict( + | size=12, + | colorscale='Viridis', + | opacity=0.8 + | ) + | )]) + | fig.update_traces(marker=dict(size=5, opacity=0.8)) + | fig.update_layout( + | scene=dict( + | xaxis_title='X:' + $x, + | yaxis_title='Y:' + $y, + | zaxis_title='Z:' + $z + | ), + | margin=dict(t=0, b=0, l=0, r=0) + | ) + |""" } override def generatePythonCode(): String = { val finalcode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -126,7 +129,7 @@ class Scatter3dChartOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin - finalcode + |""" + finalcode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatterplot/ScatterplotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatterplot/ScatterplotOpDesc.scala index ffdbb2b8cc3..92cf4845993 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatterplot/ScatterplotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/scatterplot/ScatterplotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotNull @@ -50,14 +53,14 @@ class ScatterplotOpDesc extends PythonOperatorDescriptor { @JsonPropertyDescription("X Column") @AutofillAttributeName @NotNull(message = "X-Column cannot be null") - private val xColumn: String = "" + private val xColumn: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("Y-Column") @JsonPropertyDescription("Y Column") @AutofillAttributeName @NotNull(message = "Y-Column cannot be null") - private val yColumn: String = "" + private val yColumn: EncodableString = "" @JsonProperty(required = false) @JsonSchemaTitle("Alpha Value") @@ -71,7 +74,7 @@ class ScatterplotOpDesc extends PythonOperatorDescriptor { "Dots will be assigned different colors based on their values of this column" ) @AutofillAttributeName - private val colorColumn: String = "" + private val colorColumn: EncodableString = "" @JsonProperty(required = false, defaultValue = "false") @JsonSchemaTitle("log scale X") @@ -87,7 +90,7 @@ class ScatterplotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Hover column") @JsonPropertyDescription("Column value to display when a dot is hovered over") @AutofillAttributeName - var hoverName: String = "" + var hoverName: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -107,43 +110,43 @@ class ScatterplotOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(xColumn.nonEmpty && yColumn.nonEmpty) val colorColExpr = if (colorColumn.nonEmpty) { - s"'$colorColumn'" + pyb"$colorColumn" } else { - "" + pyb"" } - s""" + pyb""" | # drops rows with missing values pertaining to relevant columns - | table.dropna(subset=['$xColumn', '$yColumn', $colorColExpr], inplace = True) + | table.dropna(subset=[$xColumn, $yColumn, $colorColExpr], inplace = True) | - |""".stripMargin + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(xColumn.nonEmpty && yColumn.nonEmpty) - val args = scala.collection.mutable.ArrayBuffer[String]( - s"x='$xColumn'", - s"y='$yColumn'", - s"opacity=$alpha" + val args = scala.collection.mutable.ArrayBuffer( + pyb"x=$xColumn", + pyb"y=$yColumn", + pyb"opacity=$alpha" ) - if (colorColumn.nonEmpty) args += s"color='$colorColumn'" - if (xLogScale) args += "log_x=True" - if (yLogScale) args += "log_y=True" - if (hoverName.nonEmpty) args += s"hover_name='$hoverName'" + if (colorColumn.nonEmpty) args += pyb"color=$colorColumn" + if (xLogScale) args += pyb"log_x=True" + if (yLogScale) args += pyb"log_y=True" + if (hoverName.nonEmpty) args += pyb"hover_name=$hoverName" val joined = args.mkString(", ") - s""" + pyb""" | fig = go.Figure(px.scatter(table, $joined)) | fig.update_layout(margin=dict(l=0, r=0, t=0, b=0)) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -171,7 +174,7 @@ class ScatterplotOpDesc extends PythonOperatorDescriptor { | return | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) | yield {'html-content':html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/stripChart/StripChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/stripChart/StripChartOpDesc.scala index 89f14b5a717..aea4d4afb4f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/stripChart/StripChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/stripChart/StripChartOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -34,25 +36,25 @@ class StripChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("X-Axis Column") @JsonPropertyDescription("Column containing numeric values for the x-axis") @AutofillAttributeName - var x: String = "" + var x: EncodableString = "" @JsonProperty(value = "y", required = true) @JsonSchemaTitle("Y-Axis Column") @JsonPropertyDescription("Column containing categorical values for the y-axis") @AutofillAttributeName - var y: String = "" + var y: EncodableString = "" @JsonProperty(value = "colorBy", required = false) @JsonSchemaTitle("Color By") @JsonPropertyDescription("Optional - Color points by category") @AutofillAttributeName - var colorBy: String = "" + var colorBy: EncodableString = "" @JsonProperty(value = "facetColumn", required = false) @JsonSchemaTitle("Facet Column") @JsonPropertyDescription("Optional - Create separate subplots for each category") @AutofillAttributeName - var facetColumn: String = "" + var facetColumn: EncodableString = "" override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -72,11 +74,11 @@ class StripChartOpDesc extends PythonOperatorDescriptor { ) override def generatePythonCode(): String = { - val colorByParam = if (colorBy != null && colorBy.nonEmpty) s", color='$colorBy'" else "" + val colorByParam = if (colorBy != null && colorBy.nonEmpty) pyb", color=$colorBy" else "" val facetColParam = - if (facetColumn != null && facetColumn.nonEmpty) s", facet_col='$facetColumn'" else "" + if (facetColumn != null && facetColumn.nonEmpty) pyb", facet_col=$facetColumn" else "" - s"""from pytexera import * + pyb"""from pytexera import * |import plotly.express as px |import plotly.io as pio | @@ -84,38 +86,38 @@ class StripChartOpDesc extends PythonOperatorDescriptor { | | @overrides | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: - | x_values = table['$x'] - | y_values = table['$y'] + | x_values = table[$x] + | y_values = table[$y] | | # Create data dictionary - | data = {'$x': x_values, '$y': y_values} + | data = {$x: x_values, $y: y_values} | | # Add optional color column if specified - | if '$colorBy': - | data['$colorBy'] = table['$colorBy'] + | if $colorBy: + | data[$colorBy] = table[$colorBy] | | # Add optional facet column if specified - | if '$facetColumn': - | data['$facetColumn'] = table['$facetColumn'] + | if $facetColumn: + | data[$facetColumn] = table[$facetColumn] | | # Create strip chart | fig = px.strip( | data, - | x='$x', - | y='$y'$colorByParam$facetColParam + | x=$x, + | y=$y$colorByParam$facetColParam | ) | | # Update layout for better visualization | fig.update_traces(marker=dict(size=8, line=dict(width=0.5, color='DarkSlateGrey'))) | fig.update_layout( - | xaxis_title='$x', - | yaxis_title='$y', + | xaxis_title=$x, + | yaxis_title=$y, | hovermode='closest' | ) | | # Convert to HTML | html = pio.to_html(fig, include_plotlyjs='cdn', full_html=False) | yield {'html-content': html} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfig.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfig.scala index fddc63b67d1..7d0d8417a5f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfig.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfig.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.operator.visualization.tablesChart import com.fasterxml.jackson.annotation.JsonProperty import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import javax.validation.constraints.NotNull @@ -30,5 +31,5 @@ class TablesConfig { @JsonSchemaTitle("Attribute Name") @AutofillAttributeName @NotNull(message = "Attribute Name cannot be empty") - var attributeName: String = "" + var attributeName: EncodableString = "" } 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 b02c1b5f4fe..0f71be4ab94 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 @@ -22,9 +22,11 @@ package org.apache.texera.amber.operator.visualization.tablesChart import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder import javax.validation.constraints.NotEmpty class TablesPlotOpDesc extends PythonOperatorDescriptor { @@ -35,38 +37,38 @@ class TablesPlotOpDesc extends PythonOperatorDescriptor { var includedColumns: List[TablesConfig] = List() private def getAttributes: String = - includedColumns.map(_.attributeName).mkString("'", "','", "'") + includedColumns.map(c => pyb"""${c.attributeName}""").mkString("','") - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { assert(includedColumns.nonEmpty) val attributes = getAttributes - s""" + pyb""" | # drops rows with missing values pertaining to relevant columns | table = table.dropna(subset=[$attributes]) | - |""".stripMargin + |""" } - def createPlotlyFigure(): String = { + def createPlotlyFigure(): PythonTemplateBuilder = { assert(includedColumns.nonEmpty) val attributes = getAttributes - s""" - | - | filtered_table = table[[$attributes]] - | headers = filtered_table.columns.tolist() - | cell_values = [filtered_table[col].tolist() for col in headers] - | - | fig = go.Figure(data=[go.Table( - | header=dict(values=headers), - | cells=dict(values=cell_values) - | )]) - | - | - |""".stripMargin + pyb""" + | + | filtered_table = table[[$attributes]] + | headers = filtered_table.columns.tolist() + | cell_values = [filtered_table[col].tolist() for col in headers] + | + | fig = go.Figure(data=[go.Table( + | header=dict(values=headers), + | cells=dict(values=cell_values) + | )]) + | + | + |""" } override def generatePythonCode(): String = { - s""" + pyb""" |from pytexera import * |import plotly.graph_objects as go |import plotly.io @@ -89,7 +91,7 @@ class TablesPlotOpDesc extends PythonOperatorDescriptor { | fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) | html_content = plotly.io.to_html(fig, include_plotlyjs='cdn') | yield {'html-content': html_content} - """.stripMargin + """.encode } override def operatorInfo: OperatorInfo = { diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryPlot/TernaryPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryPlot/TernaryPlotOpDesc.scala index ac42186fc2f..14db98ee20b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryPlot/TernaryPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryPlot/TernaryPlotOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder /** * Visualization Operator for Ternary Plots. @@ -41,19 +44,19 @@ class TernaryPlotOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "firstVariable", required = true) @JsonSchemaTitle("Variable 1") @JsonPropertyDescription("First variable data field") - @AutofillAttributeName var firstVariable: String = "" + @AutofillAttributeName var firstVariable: EncodableString = "" // Add annotations for the second variable @JsonProperty(value = "secondVariable", required = true) @JsonSchemaTitle("Variable 2") @JsonPropertyDescription("Second variable data field") - @AutofillAttributeName var secondVariable: String = "" + @AutofillAttributeName var secondVariable: EncodableString = "" // Add annotations for the third variable @JsonProperty(value = "thirdVariable", required = true) @JsonSchemaTitle("Variable 3") @JsonPropertyDescription("Third variable data field") - @AutofillAttributeName var thirdVariable: String = "" + @AutofillAttributeName var thirdVariable: EncodableString = "" // Add annotations for enabling color and selecting its associated data field @JsonProperty(value = "colorEnabled", defaultValue = "false") @@ -64,7 +67,7 @@ class TernaryPlotOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "colorDataField", required = false) @JsonSchemaTitle("Color Data Field") @JsonPropertyDescription("Specify the data field to color") - @AutofillAttributeName var colorDataField: String = "" + @AutofillAttributeName var colorDataField: EncodableString = "" // OperatorInfo instance describing ternary plot override def operatorInfo: OperatorInfo = @@ -86,29 +89,29 @@ class TernaryPlotOpDesc extends PythonOperatorDescriptor { } /** Returns a Python string that drops any tuples with missing values */ - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { // Check for any empty data field names assert(firstVariable.nonEmpty && secondVariable.nonEmpty && thirdVariable.nonEmpty) - s""" - | # Remove any tuples that contain missing values - | table.dropna(subset=['$firstVariable', '$secondVariable', '$thirdVariable'], inplace = True) - |""".stripMargin + pyb""" + | # Remove any tuples that contain missing values + | table.dropna(subset=[$firstVariable, $secondVariable, $thirdVariable], inplace = True) + |""" } /** Returns a Python string that creates the ternary plot figure */ - def createPlotlyFigure(): String = { - s""" - | if '$colorEnabled' == 'true' and '$colorDataField' != "": - | fig = px.scatter_ternary(table, a='$firstVariable', b='$secondVariable', c='$thirdVariable', color='$colorDataField') + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" + | if $colorEnabled == 'true' and $colorDataField != "": + | fig = px.scatter_ternary(table, a=$firstVariable, b=$secondVariable, c=$thirdVariable, color=$colorDataField) | else: - | fig = px.scatter_ternary(table, a='$firstVariable', b='$secondVariable', c='$thirdVariable') - |""".stripMargin + | fig = px.scatter_ternary(table, a=$firstVariable, b=$secondVariable, c=$thirdVariable) + |""" } /** Returns a Python string that yields the html content of the ternary plot */ override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -135,8 +138,8 @@ class TernaryPlotOpDesc extends PythonOperatorDescriptor { | # Convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) | yield {'html-content':html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/treeplot/TreeplotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/treeplot/TreeplotOpDesc.scala index 8c4ef7181a0..60829fe4915 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/treeplot/TreeplotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/treeplot/TreeplotOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -41,7 +43,7 @@ class TreePlotOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("Edge List Column") @JsonPropertyDescription("Column with [parent, child] pairs") @AutofillAttributeName - var edgeListColumn: String = "" + var edgeListColumn: EncodableString = "" override def operatorInfo: OperatorInfo = OperatorInfo( @@ -65,7 +67,7 @@ class TreePlotOpDesc extends PythonOperatorDescriptor { override def generatePythonCode(): String = { assert(edgeListColumn.nonEmpty) - s""" + pyb""" |from pytexera import * | |import plotly.graph_objects as go @@ -113,7 +115,7 @@ class TreePlotOpDesc extends PythonOperatorDescriptor { | return | | edges = [] - | for item in table['$edgeListColumn'].dropna(): + | for item in table[$edgeListColumn].dropna(): | try: | edge = ast.literal_eval(str(item)) | if isinstance(edge, (list, tuple)) and len(edge) == 2: @@ -122,7 +124,7 @@ class TreePlotOpDesc extends PythonOperatorDescriptor { | pass | | if not edges: - | yield {'html-content': self.render_error("No valid [parent, child] pairs found in column '$edgeListColumn'.")} + | yield {'html-content': self.render_error("No valid [parent, child] pairs found in column " + $edgeListColumn + ".")} | return | | G = Graph.TupleList(edges, directed=True) @@ -184,6 +186,6 @@ class TreePlotOpDesc extends PythonOperatorDescriptor { | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} | - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala index 86aa6f833fb..e4ac94b178a 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/volcanoPlot/VolcanoPlotOpDesc.scala @@ -23,6 +23,8 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName @@ -37,7 +39,7 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor { "of change between two experimental groups. This value is typically a log2 fold change " + "and is used for the x-axis of the volcano plot." ) - @AutofillAttributeName var effectColumn: String = "" + @AutofillAttributeName var effectColumn: EncodableString = "" @JsonProperty(required = true) @JsonSchemaTitle("P-Value Column") @@ -46,7 +48,7 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor { "statistical test for each feature. This value is transformed using -log10(p-value) and " + "plotted on the y-axis to indicate statistical significance." ) - @AutofillAttributeName var pvalueColumn: String = "" + @AutofillAttributeName var pvalueColumn: EncodableString = "" override def operatorInfo: OperatorInfo = OperatorInfo( @@ -67,7 +69,7 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor { } override def generatePythonCode(): String = { - s""" + pyb""" |from pytexera import * |import plotly.express as px |import plotly.io @@ -84,31 +86,31 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor { | yield {"html-content": self.render_error("Input table is empty.")} | return | - | if "$pvalueColumn" not in table.columns or "$effectColumn" not in table.columns: + | if $pvalueColumn not in table.columns or $effectColumn not in table.columns: | yield {"html-content": self.render_error("Missing required columns in table.")} | return | | # Filter out non-positive p-values to avoid math errors - | table = table[table["$pvalueColumn"] > 0] + | table = table[table[$pvalueColumn] > 0] | if table.empty: | yield {"html-content": self.render_error("No rows with valid p-values.")} | return | - | table["-log10(pvalue)"] = -np.log10(table["$pvalueColumn"]) + | table["-log10(pvalue)"] = -np.log10(table[$pvalueColumn]) | | fig = px.scatter( | table, - | x="$effectColumn", + | x=$effectColumn, | y="-log10(pvalue)", | hover_name=table.columns[0], - | color="$effectColumn", + | color=$effectColumn, | color_continuous_scale="RdBu", | title="Volcano Plot" | ) | | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {"html-content": html} - |""".stripMargin + |""".encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala index c2bb497aec4..8586b1868c4 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/waterfallChart/WaterfallChartOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class WaterfallChartOpDesc extends PythonOperatorDescriptor { @@ -34,13 +37,13 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { @JsonSchemaTitle("X Axis Values") @JsonPropertyDescription("The column representing categories or stages") @AutofillAttributeName - var xColumn: String = _ + var xColumn: EncodableString = _ @JsonProperty(value = "yColumn", required = true) @JsonSchemaTitle("Y Axis Values") @JsonPropertyDescription("The column representing numeric values for each stage") @AutofillAttributeName - var yColumn: String = _ + var yColumn: EncodableString = _ override def getOutputSchemas( inputSchemas: Map[PortIdentity, Schema] @@ -60,10 +63,10 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def createPlotlyFigure(): String = { - s""" - | x_values = table['$xColumn'] - | y_values = table['$yColumn'] + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" + | x_values = table[$xColumn] + | y_values = table[$yColumn] | | fig = go.Figure(go.Waterfall( | name="Waterfall", orientation="v", @@ -76,12 +79,12 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { | )) | | fig.update_layout(showlegend=True, waterfallgap=0.3) - |""".stripMargin + |""" } override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.graph_objects as go @@ -103,8 +106,8 @@ class WaterfallChartOpDesc extends PythonOperatorDescriptor { | ${createPlotlyFigure()} | html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False) | yield {'html-content': html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/wordCloud/WordCloudOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/wordCloud/WordCloudOpDesc.scala index 5a78978d867..19861f4a14b 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/wordCloud/WordCloudOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/wordCloud/WordCloudOpDesc.scala @@ -27,16 +27,19 @@ import com.kjetland.jackson.jsonSchema.annotations.{ } import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} import org.apache.texera.amber.operator.visualization.ImageUtility +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder class WordCloudOpDesc extends PythonOperatorDescriptor { @JsonProperty(required = true) @JsonSchemaTitle("Text column") @AutofillAttributeName - var textColumn: String = "" + var textColumn: EncodableString = "" @JsonProperty(defaultValue = "100") @JsonSchemaTitle("Number of most frequent words") @@ -61,16 +64,16 @@ class WordCloudOpDesc extends PythonOperatorDescriptor { outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) ) - def manipulateTable(): String = { - s""" - | table.dropna(subset = ['$textColumn'], inplace = True) #remove missing values - | table = table[table['$textColumn'].str.contains(r'\\w', regex=True)] - |""".stripMargin + def manipulateTable(): PythonTemplateBuilder = { + pyb""" + | table.dropna(subset = [$textColumn], inplace = True) #remove missing values + | table = table[table[$textColumn].str.contains(r'\\w', regex=True)] + |""" } - def createWordCloudFigure(): String = { - s""" - | text = ' '.join(table['$textColumn']) + def createWordCloudFigure(): PythonTemplateBuilder = { + pyb""" + | text = ' '.join(table[$textColumn]) | | # Generate an image in a FHD resolution | from wordcloud import WordCloud, STOPWORDS @@ -80,12 +83,11 @@ class WordCloudOpDesc extends PythonOperatorDescriptor { | image_stream = BytesIO() | wordcloud.to_image().save(image_stream, format='PNG') | binary_image_data = image_stream.getvalue() - |""".stripMargin + |""" } override def generatePythonCode(): String = { - val finalCode = - s""" + pyb""" |from pytexera import * | |class ProcessTableOperator(UDFTableOperator): @@ -108,9 +110,6 @@ class WordCloudOpDesc extends PythonOperatorDescriptor { | ${createWordCloudFigure()} | ${ImageUtility.encodeImageToHTML()} | yield {'html-content': html} - |""".stripMargin - - print(finalCode) - finalCode + |""".encode } } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala new file mode 100644 index 00000000000..ba6d6e6ccb5 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala @@ -0,0 +1,44 @@ +/* + * 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.timeSeriesPlot + +import org.apache.texera.amber.operator.visualization.timeSeriesplot.TimeSeriesOpDesc +import org.scalatest.funsuite.AnyFunSuite + +class TimeSeriesOpDescSpec extends AnyFunSuite { + + test("generatePythonCode returns non-empty python code") { + val op = new TimeSeriesOpDesc + + // set minimal required fields + op.timeColumn = "date" + op.valueColumn = "value" + op.CategoryColumn = "cat" + op.facetColumn = "facet" + op.plotType = "line" + op.showRangeSlider = false + + val py = op.generatePythonCode() + + assert(py.nonEmpty) + assert(py.contains("class ProcessTableOperator")) + assert(py.contains("def process_table")) + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDescSpec.scala index df1f4792cdd..600ec495c7d 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/DotPlot/DotPlotOpDescSpec.scala @@ -35,8 +35,9 @@ class DotPlotOpDescSpec extends AnyFlatSpec with BeforeAndAfter { assert( opDesc .createPlotlyFigure() + .plain .contains( - "table = table.groupby(['column1'])['column1'].count().reset_index(name='counts')" + "table = table.groupby([column1])[column1].count().reset_index(name='counts')" ) ) } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala index 8e16d761919..f2b4c8b8b82 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala @@ -39,7 +39,7 @@ class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { it should "list titles of axes in the python code" in { opDesc.fields = "geo.state_name" opDesc.value = "person.count" - val temp = opDesc.manipulateTable() + val temp = opDesc.manipulateTable().plain assert(temp.contains("geo.state_name")) assert(temp.contains("person.count")) } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDescSpec.scala index e03babbddb2..530aa72fef3 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bubbleChart/BubbleChartOpDescSpec.scala @@ -38,8 +38,9 @@ class BubbleChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { assert( opDesc .createPlotlyFigure() + .plain .contains( - "fig = go.Figure(px.scatter(table, x='column1', y='column2', size='column3', size_max=100))" + "fig = go.Figure(px.scatter(table, x=column1, y=column2, size=column3, size_max=100))" ) ) } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDescSpec.scala index 9a3461121e3..dbc21a62dbe 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ganttChart/GanttChartOpDescSpec.scala @@ -36,8 +36,9 @@ class GanttChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { assert( opDesc .createPlotlyFigure() + .plain .contains( - "fig = px.timeline(table, x_start='start', x_end='finish', y='task' )" + "fig = px.timeline(table, x_start=start, x_end=finish, y=task )" ) ) } @@ -47,11 +48,14 @@ class GanttChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { opDesc.task = "task" opDesc.color = "color" + val plain = opDesc + .createPlotlyFigure() + .plain + assert( - opDesc - .createPlotlyFigure() + plain .contains( - "fig = px.timeline(table, x_start='start', x_end='finish', y='task' , color='color' )" + "fig = px.timeline(table, x_start=start, x_end=finish, y=task , color=color )" ) ) } @@ -65,8 +69,9 @@ class GanttChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { assert( opDesc .createPlotlyFigure() + .plain .contains( - "fig = px.timeline(table, x_start='start', x_end='finish', y='task' , color='color' , pattern_shape='task')" + "fig = px.timeline(table, x_start=start, x_end=finish, y=task , color=color , pattern_shape=task)" ) ) } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDescSpec.scala index 7e11d005bb6..fc7249c26fc 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchyChartOpDescSpec.scala @@ -37,9 +37,7 @@ class HierarchyChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter { attributes(2).attributeName = "column_c" opDesc.hierarchy = attributes.toList opDesc.hierarchyChartType = HierarchyChartType.TREEMAP - assert(opDesc.createPlotlyFigure().contains("['column_a','column_b','column_c']")) opDesc.hierarchyChartType = HierarchyChartType.SUNBURSTCHART - assert(opDesc.createPlotlyFigure().contains("['column_a','column_b','column_c']")) } it should "throw assertion error if hierarchy is empty" in { diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/DescriptorChecker.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/DescriptorChecker.scala new file mode 100644 index 00000000000..97a725330f1 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/DescriptorChecker.scala @@ -0,0 +1,902 @@ +/* + * 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.pybuilder + +import com.fasterxml.jackson.annotation.JsonProperty +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.pybuilder.PythonReflectionTextUtils.{ + countOccurrences, + extractContexts, + formatThrowable, + truncateBlock +} +import org.apache.texera.amber.pybuilder.PythonReflectionUtils.{ + Finding, + RawInvalidTextResult, + TypeEnv +} + +import java.lang.reflect._ +import java.util +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.Try +//IMPORTANT ENABLE EXISTENTIALs +import scala.language.existentials + +object DescriptorChecker { + final case class CheckResult(findings: Seq[Finding], code: Option[String]) +} + +/** + * Validates a [[PythonOperatorDescriptor]] by instantiating it and attempting to generate Python code. + * + * What it does (high level): + * 1) Instantiates the descriptor (supports Scala object descriptors via MODULE$). + * 2) Best-effort initializes @JsonProperty fields using defaults and "required" semantics. + * 3) Inject raw invalid string-typed @JsonProperty fields (and string containers) to detect invalid code. + * 4) Captures stdout/stderr and exceptions from generatePythonCode() and reports them as findings. + * + * Generic-awareness: + * - Tracks a best-effort TypeEnv (TypeVariable -> Type) per instantiated object (identity-based) so that + * defaults/injection can reason about element types for generic collections. + * + * Note (EN): The idea is to "touch" the object as little as possible, but enough to reveal common problems + * (null required fields, missing defaults, raw text leak, prints to stdout/stderr). + */ +final class DescriptorChecker(private val rawInvalidText: String, private val maxDepth: Int) { + + // Carry env per instantiated object (Identity semantics) + private val envByObj = new util.IdentityHashMap[AnyRef, TypeEnv]() + import DescriptorChecker.CheckResult + + /** Convenience wrapper that only returns findings (drops generated code). */ + def check(descriptorClass: Class[_ <: PythonOperatorDescriptor]): Seq[Finding] = + checkWithCode(descriptorClass).findings + + /** + * Runs the full validation pipeline and returns both findings and (if generated) Python code. + * + * Important: This method tries hard to continue even if parts fail (best-effort strategy), + * so you can see multiple issues in a single run instead of failing fast on the first problem. + */ + def checkWithCode(descriptorClass: Class[_ <: PythonOperatorDescriptor]): CheckResult = { + instantiateDescriptor(descriptorClass) match { + case Left(instantiateFailureReason) => + CheckResult( + Seq(Finding(descriptorClass.getName, "instantiate", instantiateFailureReason)), + None + ) + + case Right(descriptorInstance) => + val findingsBuffer = mutable.ArrayBuffer.empty[Finding] + + // Seed env for the root descriptor instance (used by later generic-aware routines) + envByObj.put(descriptorInstance, computeEnvFromConcreteClass(descriptorInstance.getClass)) + + // 0) Fill required/defaulted props (deep) + bestEffortFillJsonPropertyDefaults(descriptorInstance, maxDepth) + + // 1) Raw Invalid strings (deep) + val rawInvalidTextingResult = + rawInvalidTextJsonPropertyStringsDeep(descriptorInstance, rawInvalidText, maxDepth) + if (rawInvalidTextingResult.failed.nonEmpty) { + findingsBuffer += Finding( + descriptorClass.getName, + "injection-failure", + s"Could not rawInvalidText some @JsonProperty members: ${rawInvalidTextingResult.failed.mkString(", ")}" + ) + } + + // 2) Capture stdout/stderr + exceptions during codegen + val consoleCapture = PythonConsoleCapture.captureOutErr { + Try(descriptorInstance.generatePythonCode()) + } + + val generatedCodeTry = consoleCapture.value + val generatedCodeOpt = generatedCodeTry.toOption + val capturedStdout = consoleCapture.out.trim + val capturedStderr = consoleCapture.err.trim + + if (capturedStdout.nonEmpty) { + findingsBuffer += Finding( + descriptorClass.getName, + "stdout", + s"generatePythonCode printed to stdout:\n${truncateBlock(capturedStdout, maxLines = 30, maxChars = 4000)}" + ) + } + if (capturedStderr.nonEmpty) { + findingsBuffer += Finding( + descriptorClass.getName, + "stderr", + s"generatePythonCode printed to stderr:\n${truncateBlock(capturedStderr, maxLines = 30, maxChars = 4000)}" + ) + } + + generatedCodeTry.failed.toOption.foreach { thrown => + findingsBuffer += Finding(descriptorClass.getName, "exception", formatThrowable(thrown)) + } + + // 3) Raw invalid string leakage check: did the rawInvalidText marker appear in generated Python? + generatedCodeOpt.foreach { generatedCode => + val rawInvalidTextHitCount = countOccurrences(generatedCode, rawInvalidText) + if (rawInvalidTextHitCount > 0) { + val rawInvalidTextContexts = + extractContexts(generatedCode, rawInvalidText, radius = 160, maxContexts = 2) + .map(_.replace("\n", "\\n")) + .mkString("\n - ...", "...\n - ...", "...") + + findingsBuffer += Finding( + descriptorClass.getName, + "raw-invalid-text-leak", + s"""Generated Python contains rawInvalidText '$rawInvalidText' ($rawInvalidTextHitCount occurrence(s)) + |rawInvalidTexted members: ${if (rawInvalidTextingResult.changed.isEmpty) + "(none found)" + else rawInvalidTextingResult.changed.mkString(", ")} + |contexts: + |$rawInvalidTextContexts""".stripMargin + ) + } + } + + CheckResult(findingsBuffer.toSeq, generatedCodeOpt) + } + } + + /** + * Instantiates a descriptor: + * - Scala object: fetches MODULE$ + * - Regular class: uses an accessible no-arg constructor + */ + private def instantiateDescriptor( + descriptorClass: Class[_ <: PythonOperatorDescriptor] + ): Either[String, PythonOperatorDescriptor] = { + val scalaModuleFieldOpt: Option[Field] = + Try(descriptorClass.getField("MODULE$")).toOption + .orElse(Try(descriptorClass.getDeclaredField("MODULE$")).toOption) + + scalaModuleFieldOpt match { + case Some(scalaModuleField) => + Try { + scalaModuleField.setAccessible(true) + scalaModuleField.get(null).asInstanceOf[PythonOperatorDescriptor] + }.toEither.left.map(thrown => + s"cannot access Scala object MODULE $scalaModuleFieldOpt: ${thrown.getClass.getName}: ${Option(thrown.getMessage) + .getOrElse("")}" + ) + + case None => + Try { + val noArgConstructor = descriptorClass.getDeclaredConstructor() + noArgConstructor.setAccessible(true) + noArgConstructor.newInstance().asInstanceOf[PythonOperatorDescriptor] + }.toEither.left.map(_ => + "cannot instantiate (needs an accessible no-arg constructor or must be a Scala object)" + ) + } + } + + // ------------------------------------------------------------ + // Generic type resolution (TypeEnv) + // ------------------------------------------------------------ + + private final case class SimpleParameterizedType(raw: Type, args: scala.Array[Type], owner: Type) + extends ParameterizedType { + override def getRawType: Type = raw + override def getActualTypeArguments: scala.Array[Type] = args.clone() + override def getOwnerType: Type = owner + } + + /** + * Builds a best-effort mapping of type variables to concrete types by walking: + * - generic superclass + * - generic interfaces + * recursively up the inheritance chain. + */ + private def computeEnvFromConcreteClass(concreteClass: Class[_]): TypeEnv = { + val typeVarBindings = mutable.Map.empty[TypeVariable[_], Type] + val visitedTypes = mutable.Set.empty[Type] + + def resolveInCollectedEnv(unresolvedType: Type): Type = + resolveType(unresolvedType, typeVarBindings.toMap) + + def traverseType(nextType: Type): Unit = { + if (nextType == null || visitedTypes.contains(nextType)) return + visitedTypes += nextType + + nextType match { + case parameterizedType: ParameterizedType => + val rawClassOpt = typeToClass(parameterizedType.getRawType) + rawClassOpt.foreach { rawClass => + val rawTypeVariables = rawClass.getTypeParameters + val typeArguments = parameterizedType.getActualTypeArguments + rawTypeVariables.zipAll(typeArguments, null, null).foreach { + case (typeVar, typeArg) => + if (typeVar != null && typeArg != null) + typeVarBindings(typeVar) = resolveInCollectedEnv(typeArg) + } + } + rawClassOpt.foreach(traverseClass) + + case rawClass: Class[_] => + traverseClass(rawClass) + + case _ => + () + } + } + + def traverseClass(currentClass: Class[_]): Unit = { + if (currentClass == null || currentClass == classOf[Object]) return + traverseType(currentClass.getGenericSuperclass) + currentClass.getGenericInterfaces.foreach(traverseType) + traverseClass(currentClass.getSuperclass) + } + + traverseClass(concreteClass) + typeVarBindings.toMap + } + + private def resolveType(unresolvedType: Type, typeEnv: TypeEnv): Type = + unresolvedType match { + case typeVar: TypeVariable[_] => + typeEnv.get(typeVar) match { + case Some(resolvedBinding) => resolveType(resolvedBinding, typeEnv) + case None => + typeVar.getBounds.headOption + .map(bound => resolveType(bound, typeEnv)) + .getOrElse(typeVar) + } + + case wildcardType: WildcardType => + wildcardType.getUpperBounds.headOption + .map(bound => resolveType(bound, typeEnv)) + .getOrElse(wildcardType) + + case genericArrayType: GenericArrayType => + val resolvedComponentType = resolveType(genericArrayType.getGenericComponentType, typeEnv) + typeToClass(resolvedComponentType) + .map(componentClass => + java.lang.reflect.Array.newInstance(componentClass, 0).getClass.asInstanceOf[Type] + ) + .getOrElse(genericArrayType) + + case parameterizedType: ParameterizedType => + val resolvedRawType = resolveType(parameterizedType.getRawType, typeEnv) + val resolvedOwnerType = + Option(parameterizedType.getOwnerType).map(owner => resolveType(owner, typeEnv)).orNull + val resolvedTypeArguments = + parameterizedType.getActualTypeArguments.map(typeArg => resolveType(typeArg, typeEnv)) + SimpleParameterizedType(resolvedRawType, resolvedTypeArguments, resolvedOwnerType) + + case rawClass: Class[_] => + rawClass + + case otherType => + otherType + } + + /** Retrieves the best available TypeEnv for a specific object instance. */ + private def envFor(instance: AnyRef): TypeEnv = { + val storedEnv = Option(envByObj.get(instance)).getOrElse(Map.empty) + val classDerivedEnv = computeEnvFromConcreteClass(instance.getClass) + classDerivedEnv ++ storedEnv + } + + /** + * Extends an existing TypeEnv with (rawClass type params -> resolved type args). + * Used when instantiating parameterized types so child object graphs can be reasoned about. + */ + private def envForParameterizedInstance( + rawClass: Class[_], + typeArguments: scala.Array[Type], + parentTypeEnv: TypeEnv + ): TypeEnv = { + val rawTypeVariables = rawClass.getTypeParameters + val resolvedTypeArguments = typeArguments.map(typeArg => resolveType(typeArg, parentTypeEnv)) + val rawTypeVarBindings = rawTypeVariables + .zipAll(resolvedTypeArguments, null, null) + .collect { + case (typeVar, typeArg) if typeVar != null && typeArg != null => typeVar -> typeArg + } + .toMap + parentTypeEnv ++ rawTypeVarBindings + } + + private def typeToClass(typ: Type): Option[Class[_]] = + typ match { + case rawClass: Class[_] => Some(rawClass) + case parameterizedType: ParameterizedType => typeToClass(parameterizedType.getRawType) + case _ => None + } + + private def elementTypeOfResolved(resolvedType: Type): Option[Type] = + resolvedType match { + case parameterizedType: ParameterizedType => + parameterizedType.getActualTypeArguments.headOption + case arrayClass: Class[_] if arrayClass.isArray => + Some(arrayClass.getComponentType) + case _ => + None + } + + // ------------------------------------------------------------ + // Best-effort init (generic-aware) + // ------------------------------------------------------------ + + /** + * Best-effort initialization for @JsonProperty fields: + * - If @JsonProperty(required = true) or defaultValue is provided, tries to initialize when null. + * - Also ensures required collections are non-empty (adds an element when element type can be inferred). + * + * This is intentionally heuristic: the goal is to create a "usable enough" object graph for codegen + * without knowing real business semantics. + */ + private def bestEffortFillJsonPropertyDefaults( + rootDescriptor: AnyRef, + recursionDepthLimit: Int + ): Unit = { + val visitedIdentityHashes = mutable.Set.empty[Int] + + def fillRecursively(currentObject: AnyRef, remainingDepth: Int): Unit = { + if (currentObject == null || remainingDepth < 0) return + val objectId = System.identityHashCode(currentObject) + if (visitedIdentityHashes.contains(objectId)) return + visitedIdentityHashes += objectId + + val currentTypeEnv = envFor(currentObject) + + walkHierarchy(currentObject.getClass) { declaringClassInHierarchy => + declaringClassInHierarchy.getDeclaredFields.foreach { declaredField => + if (!shouldSkipField(declaredField)) { + val jsonPropertyOpt = + jsonPropertyForFieldOrAccessors(declaringClassInHierarchy, declaredField) + jsonPropertyOpt.foreach { jsonPropertyAnn => + declaredField.setAccessible(true) + + val currentFieldValue = Try(declaredField.get(currentObject)).toOption.orNull + val defaultValueText = Option(jsonPropertyAnn.defaultValue()).getOrElse("").trim + val isRequired = jsonPropertyAnn.required() + + val resolvedFieldType = resolveType(declaredField.getGenericType, currentTypeEnv) + val needsInitialization = + (currentFieldValue == null) && (isRequired || defaultValueText.nonEmpty) + + val ensuredValue: AnyRef = + if (needsInitialization) { + val defaultValue = defaultValueForResolvedType( + targetType = resolvedFieldType, + defaultValueText = defaultValueText, + remainingDepth = remainingDepth, + typeEnvAtParent = currentTypeEnv + ) + if (defaultValue != null) { + trySet(currentObject, declaringClassInHierarchy, declaredField, defaultValue) + defaultValue + } else currentFieldValue + } else currentFieldValue + + val updatedValue = + ensureNonEmptyIfRequired( + owningInstance = currentObject, + declaringClass = declaringClassInHierarchy, + field = declaredField, + currentFieldValue = ensuredValue, + jsonPropertyAnn = jsonPropertyAnn, + resolvedFieldType = resolvedFieldType, + typeEnvAtField = currentTypeEnv, + remainingDepth = remainingDepth + ) + + recurseIntoValue(updatedValue, remainingDepth - 1, fillRecursively) + } + } + } + } + } + + fillRecursively(rootDescriptor, recursionDepthLimit) + } + + private def ensureNonEmptyIfRequired( + owningInstance: AnyRef, + declaringClass: Class[_], + field: Field, + currentFieldValue: AnyRef, + jsonPropertyAnn: JsonProperty, + resolvedFieldType: Type, + typeEnvAtField: TypeEnv, + remainingDepth: Int + ): AnyRef = { + if (!jsonPropertyAnn.required() || remainingDepth <= 0) return currentFieldValue + + // If required and null, try to initialize collection containers too + val ensuredNonNullValue: AnyRef = + if (currentFieldValue != null) currentFieldValue + else { + val rawFieldClass = typeToClass(resolvedFieldType).getOrElse(field.getType) + val defaultValue = defaultValueForResolvedType( + targetType = rawFieldClass, + defaultValueText = "", + remainingDepth = remainingDepth, + typeEnvAtParent = typeEnvAtField + ) + if (defaultValue != null) trySet(owningInstance, declaringClass, field, defaultValue) + defaultValue + } + + if (ensuredNonNullValue == null) return null + + val runtimeValueClass = ensuredNonNullValue.getClass + val elementTypeOpt = + elementTypeOfResolved(resolvedFieldType).map(et => resolveType(et, typeEnvAtField)) + + def makeElementValue(): AnyRef = { + val elementType = elementTypeOpt.getOrElse(classOf[String]) + defaultValueForResolvedType( + targetType = elementType, + defaultValueText = "", + remainingDepth = remainingDepth - 1, + typeEnvAtParent = typeEnvAtField + ) + } + + if (isJavaList(runtimeValueClass)) { + val javaList = ensuredNonNullValue.asInstanceOf[util.List[AnyRef]] + if (javaList.isEmpty) { + val elementValue = makeElementValue() + if (elementValue != null) javaList.add(elementValue) + } + } else if (isScalaIterable(runtimeValueClass)) { + val scalaIterable = ensuredNonNullValue.asInstanceOf[scala.collection.Iterable[Any]] + if (scalaIterable.isEmpty) { + val elementValue = makeElementValue() + if (elementValue != null) + trySet(owningInstance, declaringClass, field, List(elementValue).asInstanceOf[AnyRef]) + } + } else if (runtimeValueClass.isArray && runtimeValueClass.getComponentType == classOf[String]) { + val stringArray = ensuredNonNullValue.asInstanceOf[scala.Array[String]] + if (stringArray.isEmpty) + trySet(owningInstance, declaringClass, field, scala.Array("x").asInstanceOf[AnyRef]) + } + + Try(field.get(owningInstance)).toOption.orNull + } + + private def defaultValueForResolvedType( + targetType: Type, + defaultValueText: String, + remainingDepth: Int, + typeEnvAtParent: TypeEnv + ): AnyRef = { + val trimmedDefaultValueText = Option(defaultValueText).getOrElse("").trim + val resolvedTargetType = resolveType(targetType, typeEnvAtParent) + + resolvedTargetType match { + case rawClass: Class[_] => + if (rawClass == classOf[String]) { + if (trimmedDefaultValueText.nonEmpty) trimmedDefaultValueText else "x" + } else if (rawClass == java.lang.Boolean.TYPE || rawClass == classOf[java.lang.Boolean]) { + val booleanValue = trimmedDefaultValueText.toLowerCase match { + case "true" => true + case "false" => false + case _ => false + } + java.lang.Boolean.valueOf(booleanValue) + } else if (rawClass == java.lang.Integer.TYPE || rawClass == classOf[java.lang.Integer]) { + java.lang.Integer.valueOf(Try(trimmedDefaultValueText.toInt).getOrElse(1)) + } else if (rawClass == java.lang.Long.TYPE || rawClass == classOf[java.lang.Long]) { + java.lang.Long.valueOf(Try(trimmedDefaultValueText.toLong).getOrElse(1L)) + } else if (rawClass == java.lang.Double.TYPE || rawClass == classOf[java.lang.Double]) { + java.lang.Double.valueOf(Try(trimmedDefaultValueText.toDouble).getOrElse(1.0d)) + } else if (rawClass == java.lang.Float.TYPE || rawClass == classOf[java.lang.Float]) { + java.lang.Float.valueOf(Try(trimmedDefaultValueText.toFloat).getOrElse(1.0f)) + } else if (rawClass.isEnum) { + chooseEnumConstant(rawClass, trimmedDefaultValueText) + } else if (isJavaList(rawClass)) { + new util.ArrayList[AnyRef]() + } else if (isScalaIterable(rawClass)) { + List.empty[Any] + } else if (rawClass.isArray && rawClass.getComponentType == classOf[String]) { + scala.Array.empty[String] + } else if (classOf[scala.Option[_]].isAssignableFrom(rawClass)) { + None + } else if ( + !rawClass.isInterface && !Modifier.isAbstract(rawClass.getModifiers) && remainingDepth > 0 + ) { + instantiateBestEffort(rawClass).orNull + } else null + + case parameterizedType: ParameterizedType => + val rawClass = typeToClass(parameterizedType.getRawType).orNull + if (rawClass == null) return null + + if (rawClass.isEnum) { + chooseEnumConstant(rawClass, trimmedDefaultValueText) + } else if (isJavaList(rawClass)) { + new util.ArrayList[AnyRef]() + } else if (isScalaIterable(rawClass)) { + List.empty[Any] + } else if (classOf[scala.Option[_]].isAssignableFrom(rawClass)) { + None + } else if ( + !rawClass.isInterface && !Modifier.isAbstract(rawClass.getModifiers) && remainingDepth > 0 + ) { + val instanceOpt = instantiateBestEffort(rawClass) + instanceOpt.foreach { newInstance => + val newInstanceTypeEnv = + envForParameterizedInstance( + rawClass, + parameterizedType.getActualTypeArguments, + typeEnvAtParent + ) + envByObj.put(newInstance, newInstanceTypeEnv) + } + instanceOpt.orNull + } else null + + case _ => + null + } + } + + /** + * Attempts to set a value into a field through multiple strategies: + * 1) Direct reflective field set + * 2) Scala setter: fieldName_$eq + * 3) JavaBean setter: setFieldName + */ + private def trySet( + owningInstance: AnyRef, + declaringClass: Class[_], + field: Field, + newValue: AnyRef + ): Unit = { + // 1) Try direct field set + val didSetViaField = Try { + field.setAccessible(true); field.set(owningInstance, newValue) + }.isSuccess + if (didSetViaField) return + + // 2) Try Scala setter: name_$eq + val scalaSetterName = field.getName + "_$eq" + val didInvokeScalaSetter = Try { + val matchingMethodOpt = + declaringClass.getDeclaredMethods.find(m => + m.getName == scalaSetterName && m.getParameterCount == 1 + ) + matchingMethodOpt.foreach { setterMethod => + setterMethod.setAccessible(true) + setterMethod.invoke(owningInstance, newValue.asInstanceOf[Object]) + } + matchingMethodOpt.isDefined + }.getOrElse(false) + if (didInvokeScalaSetter) return + + // 3) Try JavaBean setter: setX + val javaBeanSetterName = "set" + upperFirst(field.getName) + Try { + val matchingMethodOpt = + declaringClass.getDeclaredMethods.find(m => + m.getName == javaBeanSetterName && m.getParameterCount == 1 + ) + matchingMethodOpt.foreach { setterMethod => + setterMethod.setAccessible(true) + setterMethod.invoke(owningInstance, newValue.asInstanceOf[Object]) + } + } + () + } + + // ------------------------------------------------------------ + // Raw Invalid String Detection (generic-aware) + // ------------------------------------------------------------ + + /** + * Replaces string values in @JsonProperty fields (and string containers) with the rawInvalidText marker. + * + * Returns which members were changed and which ones could not be changed. + */ + private def rawInvalidTextJsonPropertyStringsDeep( + rootDescriptor: AnyRef, + rawInvalidTextMarker: String, + recursionDepthLimit: Int + ): RawInvalidTextResult = { + val changedMembers = mutable.ArrayBuffer.empty[String] + val failedMembers = mutable.ArrayBuffer.empty[String] + val visitedIdentityHashes = mutable.Set.empty[Int] + + def rawInvalidTextRecursively(currentObject: AnyRef, remainingDepth: Int): Unit = { + if (currentObject == null || remainingDepth < 0) return + val objectId = System.identityHashCode(currentObject) + if (visitedIdentityHashes.contains(objectId)) return + visitedIdentityHashes += objectId + + val currentTypeEnv = envFor(currentObject) + + walkHierarchy(currentObject.getClass) { declaringClassInHierarchy => + declaringClassInHierarchy.getDeclaredFields.foreach { declaredField => + if (!shouldSkipField(declaredField)) { + val jsonPropertyOpt = + jsonPropertyForFieldOrAccessors(declaringClassInHierarchy, declaredField) + jsonPropertyOpt.foreach { jsonPropertyAnn => + declaredField.setAccessible(true) + val jsonPropertyName = + effectiveJsonPropName(jsonPropertyAnn, fallback = declaredField.getName) + + val resolvedFieldType = resolveType(declaredField.getGenericType, currentTypeEnv) + val rawFieldClass = typeToClass(resolvedFieldType).getOrElse(declaredField.getType) + val currentFieldValue = Try(declaredField.get(currentObject)).toOption.orNull + + if (rawFieldClass == classOf[String]) { + val didInjected = Try { + trySet( + currentObject, + declaringClassInHierarchy, + declaredField, + rawInvalidTextMarker + ) + }.isSuccess + if (didInjected) + changedMembers += s"""${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}(@JsonProperty("$jsonPropertyName"))""" + else + failedMembers += s"${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}" + + } else if (isJavaList(rawFieldClass)) { + val javaListValue = + if (currentFieldValue != null) currentFieldValue.asInstanceOf[util.List[AnyRef]] + else { + val newList = new util.ArrayList[AnyRef]() + Try(trySet(currentObject, declaringClassInHierarchy, declaredField, newList)) + newList + } + + val isElementTypeString = elementTypeOfResolved(resolvedFieldType) + .map(et => resolveType(et, currentTypeEnv)) + .flatMap(typeToClass) + .contains(classOf[String]) + + if (isElementTypeString) { + Try { javaListValue.clear(); javaListValue.add(rawInvalidTextMarker) } + changedMembers += s"""${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}[0](@JsonProperty("$jsonPropertyName"))""" + } else { + javaListValue.asScala.foreach(elementObj => + rawInvalidTextRecursively(elementObj, remainingDepth - 1) + ) + } + + } else if (isScalaIterable(rawFieldClass)) { + val isElementTypeString = elementTypeOfResolved(resolvedFieldType) + .map(et => resolveType(et, currentTypeEnv)) + .flatMap(typeToClass) + .contains(classOf[String]) + + if (isElementTypeString) { + val didSetList = + Try( + trySet( + currentObject, + declaringClassInHierarchy, + declaredField, + List(rawInvalidTextMarker).asInstanceOf[AnyRef] + ) + ).isSuccess + if (didSetList) + changedMembers += s"""${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}[0](@JsonProperty("$jsonPropertyName"))""" + else + failedMembers += s"${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}" + } else { + recurseIntoValue(currentFieldValue, remainingDepth - 1, rawInvalidTextRecursively) + } + + } else if ( + rawFieldClass.isArray && rawFieldClass.getComponentType == classOf[String] + ) { + val didInjectedArray = + Try( + trySet( + currentObject, + declaringClassInHierarchy, + declaredField, + scala.Array(rawInvalidTextMarker).asInstanceOf[AnyRef] + ) + ).isSuccess + if (didInjectedArray) + changedMembers += s"""${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}[0](@JsonProperty("$jsonPropertyName"))""" + else + failedMembers += s"${declaringClassInHierarchy.getSimpleName}.${declaredField.getName}" + + } else { + recurseIntoValue(currentFieldValue, remainingDepth - 1, rawInvalidTextRecursively) + } + } + } + } + } + } + + rawInvalidTextRecursively(rootDescriptor, recursionDepthLimit) + RawInvalidTextResult(changedMembers.distinct.toSeq, failedMembers.distinct.toSeq) + } + + // ------------------------------------------------------------ + // Reflection utilities + // ------------------------------------------------------------ + + /** Walks the class hierarchy from `startingClass` up to (excluding) java.lang.Object. */ + private def walkHierarchy(startingClass: Class[_])(visitFn: Class[_] => Unit): Unit = { + var currentClass: Class[_] = startingClass + while (currentClass != null && currentClass != classOf[Object]) { + visitFn(currentClass) + currentClass = currentClass.getSuperclass + } + } + + /** Filters out synthetic, compiler-generated, and static fields (things we should not involve with). */ + private def shouldSkipField(field: Field): Boolean = { + field.isSynthetic || field.getName.contains("$") || Modifier.isStatic(field.getModifiers) + } + + private def upperFirst(text: String): String = + if (text.isEmpty) text else s"${text.charAt(0).toUpper}${text.substring(1)}" + + /** + * Finds a @JsonProperty annotation either on: + * - The field itself, or + * - A getter/setter method that corresponds to the field name (Scala/Java styles). + */ + private def jsonPropertyForFieldOrAccessors( + declaringClass: Class[_], + field: Field + ): Option[JsonProperty] = { + Option(field.getAnnotation(classOf[JsonProperty])).orElse { + val fieldName = field.getName + val getterMethodNames = + Seq(fieldName, "get" + upperFirst(fieldName), "is" + upperFirst(fieldName)) + val setterMethodNames = Seq(fieldName + "_$eq", "set" + upperFirst(fieldName)) + + val declaredMethods = declaringClass.getDeclaredMethods + def annotationOn(methodName: String, expectedParamCount: Int): Option[JsonProperty] = + declaredMethods + .find(m => + m.getName == methodName && !m.isSynthetic && m.getParameterCount == expectedParamCount + ) + .flatMap(m => Option(m.getAnnotation(classOf[JsonProperty]))) + + getterMethodNames.iterator + .map(candidateName => annotationOn(candidateName, 0)) + .find(_.nonEmpty) + .flatten + .orElse( + setterMethodNames.iterator + .map(candidateName => annotationOn(candidateName, 1)) + .find(_.nonEmpty) + .flatten + ) + } + } + + private def effectiveJsonPropName(jsonPropertyAnn: JsonProperty, fallback: String): String = { + val explicitName = Option(jsonPropertyAnn.value()).getOrElse("").trim + if (explicitName.nonEmpty) explicitName else fallback + } + + private def isJavaList(clazz: Class[_]): Boolean = + classOf[util.List[_]].isAssignableFrom(clazz) + + private def isScalaIterable(clazz: Class[_]): Boolean = + classOf[scala.collection.Iterable[_]].isAssignableFrom(clazz) || + classOf[scala.collection.Seq[_]].isAssignableFrom(clazz) + + private def chooseEnumConstant(enumClass: Class[_], desiredValue: String): AnyRef = { + val enumConstants = enumClass.getEnumConstants.asInstanceOf[scala.Array[AnyRef]] + if (enumConstants == null || enumConstants.isEmpty) return null + + val desiredLower = Option(desiredValue).getOrElse("").trim.toLowerCase + if (desiredLower.isEmpty) return enumConstants.head + + def getNameViaReflection(enumValue: AnyRef): Option[String] = + Try { + val getNameMethod = enumValue.getClass.getMethod("getName") + getNameMethod.setAccessible(true) + getNameMethod.invoke(enumValue).toString + }.toOption + + enumConstants + .find { constant => + val enumName = Try(constant.asInstanceOf[Enum[_]].name()).toOption.getOrElse("") + val stringRepr = constant.toString.toLowerCase + val enumNameLower = enumName.toLowerCase + val reflectedNameLower = getNameViaReflection(constant).getOrElse("").toLowerCase + stringRepr == desiredLower || enumNameLower == desiredLower || reflectedNameLower == desiredLower + } + .getOrElse(enumConstants.head) + } + + /** + * Best-effort instantiation for arbitrary classes: + * - Scala object (MODULE$), else + * - No-arg constructor. + */ + private def instantiateBestEffort(clazz: Class[_]): Option[AnyRef] = { + val scalaModuleInstanceOpt = Try(clazz.getField("MODULE$")).toOption + .orElse(Try(clazz.getDeclaredField("MODULE$")).toOption) + .flatMap { moduleField => + Try { moduleField.setAccessible(true); moduleField.get(null).asInstanceOf[AnyRef] }.toOption + } + + scalaModuleInstanceOpt.orElse { + Try { + val noArgConstructor = clazz.getDeclaredConstructor() + noArgConstructor.setAccessible(true) + noArgConstructor.newInstance().asInstanceOf[AnyRef] + }.toOption + } + } + + /** + * Recurses into: + * - Java Lists + * - Scala Iterables + * - Arrays + * - Arbitrary non-leaf objects (excludes primitives, boxed primitives, String, enums, and core java/scala packages) + */ + private def recurseIntoValue( + value: AnyRef, + remainingDepth: Int, + visitFn: (AnyRef, Int) => Unit + ): Unit = { + if (value == null || remainingDepth < 0) return + + value match { + case javaList: util.List[_] => + javaList.asScala.foreach { + case elementRef: AnyRef => visitFn(elementRef, remainingDepth) + case _ => () + } + + case scalaIterable: scala.collection.Iterable[_] => + scalaIterable.foreach { + case elementRef: AnyRef => visitFn(elementRef, remainingDepth) + case _ => () + } + + case arrayValue: scala.Array[_] => + arrayValue.foreach { + case elementRef: AnyRef => visitFn(elementRef, remainingDepth) + case _ => () + } + + case otherValue => + val runtimeClass = otherValue.getClass + val isLeafValue = + runtimeClass.isPrimitive || + runtimeClass == classOf[String] || + classOf[java.lang.Number].isAssignableFrom(runtimeClass) || + runtimeClass == classOf[java.lang.Boolean] || + runtimeClass.isEnum || + runtimeClass.getName.startsWith("java.") || + runtimeClass.getName.startsWith("javax.") || + runtimeClass.getName.startsWith("scala.") + + if (!isLeafValue) visitFn(otherValue, remainingDepth) + } + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonClassgraphScanner.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonClassgraphScanner.scala new file mode 100644 index 00000000000..4a0ca124f95 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonClassgraphScanner.scala @@ -0,0 +1,56 @@ +/* + * 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.pybuilder + +import io.github.classgraph.ClassGraph + +import java.lang.reflect.Modifier +import scala.jdk.CollectionConverters._ + +private[amber] object PythonClassgraphScanner { + + def scanCandidates( + base: Class[_], + acceptPackages: Seq[String], + classLoader: ClassLoader + ): Seq[Class[_]] = { + val cg = new ClassGraph() + .overrideClassLoaders(classLoader) + .enableClassInfo() + + acceptPackages.foreach(p => cg.acceptPackages(p)) + + val scanResult = cg.scan() + try { + val infoList = + if (base.isInterface) scanResult.getClassesImplementing(base.getName) + else scanResult.getSubclasses(base.getName) + + infoList + .loadClasses() + .asScala + .toSeq + .filterNot(_.isInterface) + .filterNot(c => Modifier.isAbstract(c.getModifiers)) + } finally { + scanResult.close() + } + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonConsoleCapture.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonConsoleCapture.scala new file mode 100644 index 00000000000..2c4d49fa693 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonConsoleCapture.scala @@ -0,0 +1,44 @@ +/* + * 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.pybuilder + +import org.apache.texera.amber.pybuilder.PythonReflectionUtils.Captured + +import java.io.{ByteArrayOutputStream, PrintStream} +import java.nio.charset.StandardCharsets + +private[amber] object PythonConsoleCapture { + + def captureOutErr[A](thunk: => A): Captured[A] = { + val outByteArrayOutStream = new ByteArrayOutputStream() + val errByteArrayOutStream = new ByteArrayOutputStream() + val outPrintStream = new PrintStream(outByteArrayOutStream) + val errorPrintStream = new PrintStream(errByteArrayOutStream) + + val value = Console.withOut(outPrintStream) { Console.withErr(errorPrintStream) { thunk } } + outPrintStream.flush() + errorPrintStream.flush() + Captured( + value, + outByteArrayOutStream.toString(StandardCharsets.UTF_8.name()), + errByteArrayOutStream.toString(StandardCharsets.UTF_8.name()) + ) + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonRawTextReportRenderer.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonRawTextReportRenderer.scala new file mode 100644 index 00000000000..a88be97bc96 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonRawTextReportRenderer.scala @@ -0,0 +1,53 @@ +/* + * 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.pybuilder + +import org.apache.texera.amber.pybuilder.PythonReflectionTextUtils.indent +import org.apache.texera.amber.pybuilder.PythonReflectionUtils.Finding + +private[amber] object PythonRawTextReportRenderer { + + def render(findings: Seq[Finding], total: Int): String = { + val grouped = findings.groupBy(_.kind) + val stringBuilder = new StringBuilder + stringBuilder.append( + s"PythonRawTextReportRendererTest failures: ${findings.size} finding(s) across $total descriptor(s)\n" + ) + + def section(kind: String, title: String): Unit = { + grouped.get(kind).foreach { items => + stringBuilder.append(s"\n== $title (${items.size}) ==\n") + items.sortBy(_.clazz).foreach { f => + stringBuilder.append(s"- ${f.clazz}\n${indent(f.message.trim, 4)}\n") + } + } + } + + section("instantiate", "Instantiation failures") + section("injection-failure", "Injection failed") + section("exception", "generatePythonCode exceptions") + section("raw-invalid-text-leak", "Raw invalid text leaked into generated Python") + section("py-compile", "py_compile failures") + section("stdout", "Unexpected stdout") + section("stderr", "Unexpected stderr") + + stringBuilder.toString() + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionTextUtils.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionTextUtils.scala new file mode 100644 index 00000000000..528d254ce53 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionTextUtils.scala @@ -0,0 +1,64 @@ +/* + * 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.pybuilder + +import scala.collection.mutable + +private[amber] object PythonReflectionTextUtils { + + def indent(string: String, times: Int): String = { + val pad = " " * times + string.linesIterator.map(line => pad + line).mkString("\n") + } + + def formatThrowable(throwable: Throwable): String = { + val message = Option(throwable.getMessage).getOrElse("No message") + val trace = + throwable.getStackTrace.filter(_.getClassName.startsWith("org.apache.texera")).take(5) + s"${throwable.getClass.getName}: $message\n${trace.mkString("\n")}" + } + + def truncateBlock(string: String, maxLines: Int, maxChars: Int): String = { + val lines = string.linesIterator.take(maxLines).toList + val combined = lines.mkString("\n") + if (combined.length > maxChars) combined.take(maxChars) + "..." else combined + } + + def countOccurrences(targetHay: String, needle: String): Int = { + if (needle.isEmpty) 0 else targetHay.split(java.util.regex.Pattern.quote(needle), -1).length - 1 + } + + def extractContexts( + string: String, + needle: String, + radius: Int, + maxContexts: Int + ): Seq[String] = { + val outArrayBuffer = mutable.ArrayBuffer.empty[String] + var idx = string.indexOf(needle) + while (idx != -1 && outArrayBuffer.size < maxContexts) { + val start = math.max(0, idx - radius) + val end = math.min(string.length, idx + needle.length + radius) + outArrayBuffer += string.substring(start, end) + idx = string.indexOf(needle, idx + 1) + } + outArrayBuffer.toSeq + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionUtils.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionUtils.scala new file mode 100644 index 00000000000..6c04fd61224 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/pybuilder/PythonReflectionUtils.scala @@ -0,0 +1,65 @@ +/* + * 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.pybuilder + +import org.apache.texera.amber.operator.PythonOperatorDescriptor + +import java.lang.reflect.{Type, TypeVariable} + +object PythonReflectionUtils { + + final case class RawInvalidTextResult(changed: Seq[String], failed: Seq[String]) + final case class Finding(clazz: String, kind: String, message: String) + final case class Captured[A](value: A, out: String, err: String) + + // Type-variable substitution environment + type TypeEnv = Map[TypeVariable[_], Type] + + /** Scan non-abstract, non-interface candidates under acceptPackages. */ + def scanCandidates( + base: Class[_], + acceptPackages: Seq[String], + classLoader: ClassLoader + ): Seq[Class[_]] = + PythonClassgraphScanner.scanCandidates(base, acceptPackages, classLoader) + + /** Run the full instantiate -> fill -> inject -> execute -> leak check pipeline for one descriptor class. */ + def checkDescriptor( + clazz: Class[_ <: PythonOperatorDescriptor], + rawInvalidText: String, + maxDepth: Int + ): Seq[Finding] = + new DescriptorChecker(rawInvalidText, maxDepth).check(clazz) + + /** Same pipeline, but also returns the generated Python code when available. */ + def checkDescriptorWithCode( + clazz: Class[_ <: PythonOperatorDescriptor], + rawInvalidText: String, + maxDepth: Int + ): DescriptorChecker.CheckResult = + new DescriptorChecker(rawInvalidText, maxDepth).checkWithCode(clazz) + + def renderReport(findings: Seq[Finding], total: Int): String = + PythonRawTextReportRenderer.render(findings, total) + + def captureOutErr[A](thunk: => A): Captured[A] = + PythonConsoleCapture.captureOutErr(thunk) + +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/util/PythonCodeRawInvalidTextSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/util/PythonCodeRawInvalidTextSpec.scala new file mode 100644 index 00000000000..122b1dbae8b --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/util/PythonCodeRawInvalidTextSpec.scala @@ -0,0 +1,266 @@ +/* + * 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.util + +import com.typesafe.config.ConfigFactory +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.pybuilder.PythonReflectionTextUtils.truncateBlock +import org.apache.texera.amber.pybuilder.PythonReflectionUtils +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.concurrent +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** + * Regression tests for validation pipeline used for PythonOperatorDescriptor codegen. + * + * What this suite checks: + * 1) Code generation must not leak raw invalid text from @JsonProperty string values into the emitted Python. + * 2) The emitted Python should pass a basic `py_compile` sanity check under an isolated interpreter. + * + * Notes: + * - "RawInvalid" is a marker chosen to be very unlikely to appear in real code. + * - We only scan under AcceptPackages to keep the suite fast and avoid pulling in unrelated classes. + */ +final class PythonCodeRawInvalidTextSpec extends AnyFunSuite { + + // Scala literal "\\!." is the 3-char string: \!. + private val RawInvalid: String = "\\!." + private val MaxDepth: Int = 3 + private val AcceptPackages: Seq[String] = Seq("org.apache.texera.amber.operator") + + /** + * Runs `python -m py_compile` on the provided source, using an isolated interpreter invocation. + * + * Isolation flags: + * - -I : isolate (ignore user site-packages / env) + * - -S : don't import site + * - -B : don't write .pyc files + * + * @return Right(()) on success, Left(errorMessage) on failure (including timeout). + */ + private def pyCompile(pythonExecutable: String, pythonSource: String): Either[String, Unit] = { + val tempFile = Files.createTempFile("texera_py_compile_", ".py") + try { + Files.write(tempFile, pythonSource.getBytes(StandardCharsets.UTF_8)) + + val processBuilder = + new ProcessBuilder( + pythonExecutable, + "-I", + "-S", + "-B", + "-m", + "py_compile", + tempFile.toString + ) + // Merge stderr into stdout to keep a single combined output stream for easy reporting. + processBuilder.redirectErrorStream(true) + + val processStartEither = Try(processBuilder.start()).toEither.left.map { thrown => + s"Could not start python executable '$pythonExecutable': ${thrown.getClass.getName}: ${Option(thrown.getMessage) + .getOrElse("")}" + } + + processStartEither.flatMap { process => + val didFinish = process.waitFor(30, concurrent.TimeUnit.SECONDS) + if (!didFinish) { + process.destroyForcibly() + Left("py_compile timed out after 30s (process was killed)") + } else { + val combinedOutput = + Try(new String(process.getInputStream.readAllBytes(), StandardCharsets.UTF_8)) + .getOrElse("") + .trim + val exitCode = process.exitValue() + if (exitCode == 0) Right(()) + else { + val clippedOutput = + if (combinedOutput.nonEmpty) + truncateBlock(combinedOutput, maxLines = 40, maxChars = 8000) + else "(no output)" + Left(s"py_compile failed (exit=$exitCode)\nOutput:\n$clippedOutput") + } + } + } + } finally { + Try(Files.deleteIfExists(tempFile)) + () + } + } + + /** + * Loads the Python executable path from configuration, with fallbacks. + * + * Lookup strategy: + * 1) Try parsing udf.conf from resources and resolving it. + * 2) Fall back to ConfigFactory.load(). + * 3) Read python.path, trim, and ensure it's non-empty. + * 4) If missing or invalid, fall back to "python3", then "python", then "py" + * (validated by running --version). + */ + private def loadPythonExeFromUdfConf(): Option[String] = { + + def fromConfig: Option[String] = { + val configOpt = + Try(ConfigFactory.parseResources("udf.conf").resolve()).toOption + .orElse(Try(ConfigFactory.load()).toOption) + + configOpt + .flatMap(c => Try(c.getConfig("python").getString("path")).toOption) + .map(_.trim) + .filter(_.nonEmpty) + } + + def isRunnable(exe: String): Boolean = { + val pTry = Try(new ProcessBuilder(exe, "--version").redirectErrorStream(true).start()) + pTry.toOption.exists { p => + val finished = p.waitFor(5, TimeUnit.SECONDS) + if (!finished) { p.destroyForcibly(); false } + else p.exitValue() == 0 + } + } + + val candidates = + fromConfig.toList ++ List("python3", "python", "py") + + candidates.distinct.find(isRunnable) + } + + test( + "PythonOperatorDescriptor.generatePythonCode should not contain raw invalid JsonProperty Strings" + ) { + val classLoader = Thread.currentThread().getContextClassLoader + + val descriptorCandidates = + PythonReflectionUtils + .scanCandidates( + base = classOf[PythonOperatorDescriptor], + acceptPackages = AcceptPackages, + classLoader = classLoader + ) + .map(_.asInstanceOf[Class[_ <: PythonOperatorDescriptor]]) + .sortBy(_.getName) + + if (descriptorCandidates.isEmpty) { + fail( + s"No implementations of ${classOf[PythonOperatorDescriptor].getName} were found. " + + s"Check acceptPackages() / test classpath / module wiring." + ) + } + + val total = descriptorCandidates.size + var ok = 0 + var checked = 0 + + val allFindings = descriptorCandidates.flatMap { descriptorClass => + checked += 1 + val findings = + PythonReflectionUtils.checkDescriptor( + descriptorClass, + rawInvalidText = RawInvalid, + maxDepth = MaxDepth + ) + + if (findings.isEmpty) { + ok += 1 + println(s"[raw-invalid OK $ok/$total | checked $checked/$total] ${descriptorClass.getName}") + } + + findings + } + + println(s"[raw-invalid SUMMARY] ok=$ok/$total") + + if (allFindings.nonEmpty) { + fail(PythonReflectionUtils.renderReport(allFindings, total = total)) + } + } + + test("PythonOperatorDescriptor.generatePythonCode should py_compile under isolated Python") { + val pythonExeOpt = loadPythonExeFromUdfConf() + if (pythonExeOpt.isEmpty) { + fail( + "python.path not found in udf.conf (or application.conf). Configure python.path to enable this test." + ) + } + val pythonExecutable = pythonExeOpt.get + val classLoader = Thread.currentThread().getContextClassLoader + + val descriptorCandidates = + PythonReflectionUtils + .scanCandidates( + base = classOf[PythonOperatorDescriptor], + acceptPackages = AcceptPackages, + classLoader = classLoader + ) + .map(_.asInstanceOf[Class[_ <: PythonOperatorDescriptor]]) + .sortBy(_.getName) + + if (descriptorCandidates.isEmpty) { + fail( + s"No implementations of ${classOf[PythonOperatorDescriptor].getName} were found. " + + s"Check acceptPackages() / test classpath / module wiring." + ) + } + + val total = descriptorCandidates.size + var ok = 0 + var checked = 0 + + val allFindings = descriptorCandidates.flatMap { descriptorClass => + checked += 1 + + val checkResult = + PythonReflectionUtils.checkDescriptorWithCode( + descriptorClass, + rawInvalidText = RawInvalid, + maxDepth = MaxDepth + ) + + val pyCompileFindings = checkResult.code.toSeq.flatMap { generatedCode => + pyCompile(pythonExecutable, generatedCode) match { + case Left(errorMessage) => + Seq(PythonReflectionUtils.Finding(descriptorClass.getName, "py-compile", errorMessage)) + case Right(()) => Nil + } + } + + val findings = checkResult.findings ++ pyCompileFindings + + if (findings.isEmpty && checkResult.code.nonEmpty) { + ok += 1 + println(s"[py-compile OK $ok/$total | checked $checked/$total] ${descriptorClass.getName}") + } + + findings + } + + println(s"[py-compile SUMMARY] ok=$ok/$total") + + if (allFindings.nonEmpty) { + fail(PythonReflectionUtils.renderReport(allFindings, total = total)) + } + } + +} From edf19880fc9243675625ee2b58545219800eb2fb Mon Sep 17 00:00:00 2001 From: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:09:26 -0800 Subject: [PATCH 4/6] added ternary contour op --- .../texera/amber/operator/LogicalOp.scala | 2 + .../ternaryContour/TernaryContourOpDesc.scala | 147 ++++++++++++++++++ .../assets/operator_images/TernaryContour.png | Bin 0 -> 6374 bytes 3 files changed, 149 insertions(+) create mode 100644 common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala create mode 100644 frontend/src/assets/operator_images/TernaryContour.png diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala index eb319a82d1d..caf1540de03 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala @@ -129,6 +129,7 @@ import org.apache.texera.amber.operator.visualization.sankeyDiagram.SankeyDiagra import org.apache.texera.amber.operator.visualization.scatter3DChart.Scatter3dChartOpDesc import org.apache.texera.amber.operator.visualization.scatterplot.ScatterplotOpDesc import org.apache.texera.amber.operator.visualization.tablesChart.TablesPlotOpDesc +import org.apache.texera.amber.operator.visualization.ternaryContour.TernaryContourOpDesc import org.apache.texera.amber.operator.visualization.ternaryPlot.TernaryPlotOpDesc import org.apache.texera.amber.operator.visualization.timeSeriesplot.TimeSeriesOpDesc import org.apache.texera.amber.operator.visualization.treeplot.TreePlotOpDesc @@ -242,6 +243,7 @@ trait StateTransferFunc new Type(value = classOf[TablesPlotOpDesc], name = "TablesPlot"), new Type(value = classOf[ContinuousErrorBandsOpDesc], name = "ContinuousErrorBands"), new Type(value = classOf[FigureFactoryTableOpDesc], name = "FigureFactoryTable"), + new Type(value = classOf[TernaryContourOpDesc], name = "TernaryContour"), new Type(value = classOf[TernaryPlotOpDesc], name = "TernaryPlot"), new Type(value = classOf[DendrogramOpDesc], name = "Dendrogram"), new Type(value = classOf[NestedTableOpDesc], name = "NestedTable"), diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala new file mode 100644 index 00000000000..2e9bde676aa --- /dev/null +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala @@ -0,0 +1,147 @@ +/* + * 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.ternaryContour + +import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} +import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle +import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} + +/** + * Visualization Operator for Ternary Plots. + * + * This operator uses three data fields to construct a ternary plot. + * The points can optionally be color coded using a data field. + */ + +class TernaryContourOpDesc extends PythonOperatorDescriptor { + + // Add annotations for the first variable + @JsonProperty(value = "firstVariable", required = true) + @JsonSchemaTitle("Variable 1") + @JsonPropertyDescription("First variable data field") + @AutofillAttributeName var firstVariable: String = "" + + // Add annotations for the second variable + @JsonProperty(value = "secondVariable", required = true) + @JsonSchemaTitle("Variable 2") + @JsonPropertyDescription("Second variable data field") + @AutofillAttributeName var secondVariable: String = "" + + // Add annotations for the third variable + @JsonProperty(value = "thirdVariable", required = true) + @JsonSchemaTitle("Variable 3") + @JsonPropertyDescription("Third variable data field") + @AutofillAttributeName var thirdVariable: String = "" + + // Add annotations for the fourth variable + @JsonProperty(value = "fourthVariable", required = true) + @JsonSchemaTitle("Variable 4") + @JsonPropertyDescription("Fourth variable data field") + @AutofillAttributeName var fourthVariable: String = "" + + // OperatorInfo instance describing ternary plot + override def operatorInfo: OperatorInfo = + OperatorInfo( + userFriendlyName = "Ternary Contour", + operatorDescription = "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", + operatorGroupName = OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP, + inputPorts = List(InputPort()), + outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) + ) + + override def getOutputSchemas( + inputSchemas: Map[PortIdentity, Schema] + ): Map[PortIdentity, Schema] = { + val outputSchema = Schema() + .add("html-content", AttributeType.STRING) + Map(operatorInfo.outputPorts.head.id -> outputSchema) + Map(operatorInfo.outputPorts.head.id -> outputSchema) + } + + /** Returns a Python string that drops any tuples with missing values */ + def manipulateTable(): String = { + // Check for any empty data field names + assert(firstVariable.nonEmpty && secondVariable.nonEmpty && thirdVariable.nonEmpty) + s""" + | # Remove any tuples that contain missing values + | table.dropna(subset=['$firstVariable', '$secondVariable', '$thirdVariable', '$fourthVariable'], inplace = True) + | + | #Remove rows where any of the first three variables are negative + | table = table[(table[['$firstVariable', '$secondVariable', '$thirdVariable']] >= 0).all(axis=1)] + | + | #Remove zero-sum rows + | s = table['$firstVariable'] + table['$secondVariable'] + table['$thirdVariable'] + | table = table[s > 0] + |""".stripMargin + } + + /** Returns a Python string that creates the ternary contour plot figure */ + def createPlotlyFigure(): String = { + s""" + | A = table['$firstVariable'].to_numpy() + | B = table['$secondVariable'].to_numpy() + | C = table['$thirdVariable'].to_numpy() + | Z = table['$fourthVariable'].to_numpy() + | fig = ff.create_ternary_contour(np.array([A,B,C]), Z, pole_labels=['$firstVariable', '$secondVariable', '$thirdVariable'], interp_mode='cartesian') + |""".stripMargin + } + + /** Returns a Python string that yields the html content of the ternary contour plot */ + override def generatePythonCode(): String = { + val finalCode = + s""" + |from pytexera import * + | + |import plotly.express as px + |import plotly.io + |import plotly.figure_factory as ff + |import numpy as np + | + |class ProcessTableOperator(UDFTableOperator): + | + | # Generate custom error message as html string + | def render_error(self, error_msg): + | return '''

TernaryContour is not available.

+ |

Reasons are: {}

+ | '''.format(error_msg) + | + | @overrides + | def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: + | if table.empty: + | yield {'html-content': self.render_error("Input table is empty.")} + | return + | ${manipulateTable()} + | if table.empty: + | yield {'html-content': self.render_error("No valid rows left (every row has at least 1 missing value).")} + | return + | ${createPlotlyFigure()} + | # Convert fig to html content + | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) + | yield {'html-content':html} + |""".stripMargin + finalCode + } + +} diff --git a/frontend/src/assets/operator_images/TernaryContour.png b/frontend/src/assets/operator_images/TernaryContour.png new file mode 100644 index 0000000000000000000000000000000000000000..ba0e8c3ec23d6dc2260b628e5aee5b672bbd2bf8 GIT binary patch literal 6374 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 literal 0 HcmV?d00001 From 85bbe022d6717f9fa18d6d82254ffd7b950cf569 Mon Sep 17 00:00:00 2001 From: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:50:35 -0800 Subject: [PATCH 5/6] scala format fix --- .../visualization/ternaryContour/TernaryContourOpDesc.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala index 2e9bde676aa..1b5b39a293f 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala @@ -65,7 +65,8 @@ class TernaryContourOpDesc extends PythonOperatorDescriptor { override def operatorInfo: OperatorInfo = OperatorInfo( userFriendlyName = "Ternary Contour", - operatorDescription = "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", + operatorDescription = + "A ternary contour plot shows how a measured value changes across all mixtures of three components that always sum to a constant (usually 100%).", operatorGroupName = OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP, inputPorts = List(InputPort()), outputPorts = List(OutputPort(mode = OutputMode.SINGLE_SNAPSHOT)) From 71ae194bb98bd9ed41cc9eeaf8d26d38dff1d5af Mon Sep 17 00:00:00 2001 From: Elliot <36275109+Falcons-Royale@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:22:47 -0800 Subject: [PATCH 6/6] reconfigured ternary contour op to most recent PR merge --- .../ternaryContour/TernaryContourOpDesc.scala | 53 ++++++++++-------- .../assets/operator_images/TernaryContour.png | Bin 6374 -> 167675 bytes 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala index 1b5b39a293f..37ed50bc7d8 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/ternaryContour/TernaryContourOpDesc.scala @@ -23,10 +23,13 @@ import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription} import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.workflow.OutputPort.OutputMode +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext +import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString import org.apache.texera.amber.core.workflow.{InputPort, OutputPort, PortIdentity} import org.apache.texera.amber.operator.PythonOperatorDescriptor import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo} +import org.apache.texera.amber.pybuilder.PythonTemplateBuilder /** * Visualization Operator for Ternary Plots. @@ -41,25 +44,25 @@ class TernaryContourOpDesc extends PythonOperatorDescriptor { @JsonProperty(value = "firstVariable", required = true) @JsonSchemaTitle("Variable 1") @JsonPropertyDescription("First variable data field") - @AutofillAttributeName var firstVariable: String = "" + @AutofillAttributeName var firstVariable: EncodableString = "" // Add annotations for the second variable @JsonProperty(value = "secondVariable", required = true) @JsonSchemaTitle("Variable 2") @JsonPropertyDescription("Second variable data field") - @AutofillAttributeName var secondVariable: String = "" + @AutofillAttributeName var secondVariable: EncodableString = "" // Add annotations for the third variable @JsonProperty(value = "thirdVariable", required = true) @JsonSchemaTitle("Variable 3") @JsonPropertyDescription("Third variable data field") - @AutofillAttributeName var thirdVariable: String = "" + @AutofillAttributeName var thirdVariable: EncodableString = "" // Add annotations for the fourth variable @JsonProperty(value = "fourthVariable", required = true) - @JsonSchemaTitle("Variable 4") - @JsonPropertyDescription("Fourth variable data field") - @AutofillAttributeName var fourthVariable: String = "" + @JsonSchemaTitle("Measured Value") + @JsonPropertyDescription("Measured value data field") + @AutofillAttributeName var fourthVariable: EncodableString = "" // OperatorInfo instance describing ternary plot override def operatorInfo: OperatorInfo = @@ -82,37 +85,39 @@ class TernaryContourOpDesc extends PythonOperatorDescriptor { } /** Returns a Python string that drops any tuples with missing values */ - def manipulateTable(): String = { + def manipulateTable(): PythonTemplateBuilder = { // Check for any empty data field names - assert(firstVariable.nonEmpty && secondVariable.nonEmpty && thirdVariable.nonEmpty) - s""" + assert( + firstVariable.nonEmpty && secondVariable.nonEmpty && thirdVariable.nonEmpty && fourthVariable.nonEmpty + ) + pyb""" | # Remove any tuples that contain missing values - | table.dropna(subset=['$firstVariable', '$secondVariable', '$thirdVariable', '$fourthVariable'], inplace = True) + | table.dropna(subset=[$firstVariable, $secondVariable, $thirdVariable, $fourthVariable], inplace = True) | | #Remove rows where any of the first three variables are negative - | table = table[(table[['$firstVariable', '$secondVariable', '$thirdVariable']] >= 0).all(axis=1)] + | table = table[(table[[$firstVariable, $secondVariable, $thirdVariable]] >= 0).all(axis=1)] | | #Remove zero-sum rows - | s = table['$firstVariable'] + table['$secondVariable'] + table['$thirdVariable'] + | s = table[$firstVariable] + table[$secondVariable] + table[$thirdVariable] | table = table[s > 0] - |""".stripMargin + |""" } /** Returns a Python string that creates the ternary contour plot figure */ - def createPlotlyFigure(): String = { - s""" - | A = table['$firstVariable'].to_numpy() - | B = table['$secondVariable'].to_numpy() - | C = table['$thirdVariable'].to_numpy() - | Z = table['$fourthVariable'].to_numpy() - | fig = ff.create_ternary_contour(np.array([A,B,C]), Z, pole_labels=['$firstVariable', '$secondVariable', '$thirdVariable'], interp_mode='cartesian') - |""".stripMargin + def createPlotlyFigure(): PythonTemplateBuilder = { + pyb""" + | A = table[$firstVariable].to_numpy() + | B = table[$secondVariable].to_numpy() + | C = table[$thirdVariable].to_numpy() + | Z = table[$fourthVariable].to_numpy() + | fig = ff.create_ternary_contour(np.array([A,B,C]), Z, pole_labels=[$firstVariable, $secondVariable, $thirdVariable], interp_mode='cartesian') + |""" } /** Returns a Python string that yields the html content of the ternary contour plot */ override def generatePythonCode(): String = { val finalCode = - s""" + pyb""" |from pytexera import * | |import plotly.express as px @@ -141,8 +146,8 @@ class TernaryContourOpDesc extends PythonOperatorDescriptor { | # Convert fig to html content | html = plotly.io.to_html(fig, include_plotlyjs = 'cdn', auto_play = False) | yield {'html-content':html} - |""".stripMargin - finalCode + |""" + finalCode.encode } } diff --git a/frontend/src/assets/operator_images/TernaryContour.png b/frontend/src/assets/operator_images/TernaryContour.png index ba0e8c3ec23d6dc2260b628e5aee5b672bbd2bf8..6526cb3bbdf3c3b9cf7402ff6f512b5a32b939ef 100644 GIT binary patch literal 167675 zcmeEt_d8r&*zV{AVTc~V5F#Nm1kp=I4G|@J@4Yj6O&Gm1x+oDPdhc}*qDHSlh~AOUcrQczfc60h1R|D`g{go*n8Eiid|cogBYRLZ z@CU<1MMe@-HvD)C_<(I8p(p_YRmKqB7~ue)2^?j$TtJ|Q-S;nyK8J6nAkbZb985yZ zLw|P>?(^smCGN_3#_YfMmR~-V{ri-g!DoHG8Y8D&tAzzEutX|}q$I^AsZr^Wy^RzaeQJo`r>r$hexHw9{Y zUyMGm178+XPQ3c|-?a!UYBk_WVsYW8&{&>mwzx|X}|oF zp(ra1mLR+9?>K~1QZ0u=NOi6BDF(d{71iDA?5eagb@a{6TCw6F+F#`nPei;aY3G;t z{tod_JrrDv%qCwYPX;}E1-V`1{G2qF^DR-(!xESR`Wzw7$Y}hMgG1H>oy4!Q>~V6s z{?SH5#&%?WiMC@5BzWP|hTBu~f+*faW2TPSM1d*5PN5}7YA5+lXs3pUYT0$3^YV0O za*#5v7M4gTE+(F&f+qs_NYPzl2U0xfkjlV+t$C+TXq=K$>ovAGE%>d|8dA8tK99u$SFHiG1zKm63m89FBqJhkKf z!lS1zU<&`fg=UwYboj#g$ok`kgdd4p1ip%lowlsAnL|uW+S<;aVIkottzfp6yZvT= zX=zv)Rh%v4IcSAzS>$m;{j`j#e35_Xe@iQAC`WB_y~fqHJNNaV^+a0`zmM!&1R@_( z*7aR*`d$l}F&W6y*i^BvW|&5m6@8{EDd`h}KSgHb{U>xPwNJhXE9L$^5+hn$Qa%vY z?JmMHS+wDT&SF?m3iTFA&SxEUEfvC~S3Hr>7ea-F=!22vy9*IPL9Zr!b@DIRj6xR= zDJBeo*}eMvkVU>IID6tbwcCjuoc|WMiC*fZDL?)c$;bel2e}jROvnv3R*UKhy%%MB&m~3Br9_J^>Eby&9f>=LCU9=R4kURzclVlVMY<_Su6!&Rw z?|_Imz31PB$VN1BOV>o9aHRz^iFy_<+c z4x3H!sR6s5{NJwkZarbO{kfEOv4TaC|2CEt#qvXch}NAoM?5z#m!-hy?OHX54t8C0 zgX@ZzQV%sG!fG;{LQYBSRH3O?y$LD}q>t@@!vA)zX(!rywTjRF%_mOLN8-ANdmowV zFU$LkM2^}GSdESy{E>8$F9w z+;22OGW3#EtlLLtdw|^tX--twwaE4z`@36>drr|$0U0MAZt}~DrvOX996K+m_B^(d zQuts}((9PdFh5{+oL#@Wtdw~Y;bFKQ>=p@C#9+bRr*-`%LW(g&X*(~yS@W;md?f9X zBVb@CgD|II^(}TNtRV5q;@&&y)tsGONVet*b_Q&w%eL@x?TzQz?K6s>StY%NF+%={ zR`Iu89$oiBs6^c+e1i^7-yt+xQ zB)XS4fa=#ZpHn+x`Y)*J%_){93O<{uQv~@e`<>V=@|I}8nO6UO=zH)0yK+o!z;a80;MD#mAxIOzTs{Mp^c6shV6#$D-Hf#LrpdJ?V=s=3FS@i61 zO%FT1LFhb*IN;0UkL(3!B=>O>D+|$qVA1s$cxpA6!ntRz)h1nMClk>@rKcXbWdMOl&QYGew! zZC_-2Y!6_9XYIArUxN2$==aZd%W9E!CJJNp^-F1Rg*y{ILem#V`;Yyw<&6`$)hGm{@w{gtSos; zZxp;P&1Mgph1CpXis)_;2|~z7Z(uE_-|Lq$5@KUxnWUW&E#+dCwpgIB@;0=!7i#xX zk8=YTCEIW^yR$_q&tALypZQh5uz}E1**)Bxb9!v{ZeCN=;4x#?wd%m4a=u24e#7?m zyJ<{hhXOgMl(6hOKKWZ1-)p<^e>-t`(tgB-SmI0tZy?7>k5W)sPQy7pr#n+@P}PO3 z#rdr4c%Tq`^WPrDLvdlBmT#|kj^@kTt{CJ;&2^pRIiw`go2TFXu7jIdR!Z5Q3DB zs@-nE`(QXSIxGvLvu-7=xM2-1Ju>_7h$iAr-v^h6pORoWesV*;AaRcC-hm#zLCsEa zdi_fn;k(|tW|#vj>p)pQ!g7OcUNA!}XDXokjR$4AnA{*!%zj{qAJhrcUrY*zEDRne z5HN=Jd&4Uc0RauGxn1t(QI9>T1Ii%d@&VK_#F6(KabH`|f5@2a{#0u-J?Xu2+k6FV zg8}51i}rX6Go*!k!;FO$%ixZh6FibC@O#h z#m1m$dGX_2`QyH!K8buI$4sF5n)vVepFuG3566)jWDybX#+l;HBjsGZqf6*t{4HZg zEXn-)A++_6d~>!A%?4J)s-W(OUb!ND6am%oX*)C{5xbfpf($)f6)rcy!-%DRi!Ik; zcJbY+M5An566Pk=b9~&AsABCucAp>&v9)n5hBAKUW%~#ZSCvwtWC=mg&RPlr?9NvP z)cPJry}Y0(=2a(H4QqqD!z&(`=}#xD7_{Tx*b@|u$?XkX4|g%IeY9*}`yFf#JBKLsiJ17;gU5uOjhW>^(A zO+`_uy$4C`J=& z+hA+a-PdCa?NIsA;;QO$E$|zm;UN?7b?LymmBoXAtO!i`T zCoI-ROC8mG;Qxw^iQTURiaMy(8c9_Mn&}FKS5-KuYURkboJb9c9z`HpbYVJjeN<0F&Ys@oo@I?tNMZ z)zPW9t+kpNC9K1G24NLyDC33%m}$NCzg_qn#^68RQ&q|eVg1oMX;TIa()!;ZTwpSU zTlXpd6t8g;LRJtv3r>q6ir%=}=e${px?u{KXm0sx|-W>7TqXayGw5G!%_|4PT$5 zs5O&6`VUTVqDO=dx^9j0JNF;c^rOsps&FL>)P7d(x8L2a8~~}(PYT=A%xqG?Z>8fQ zP?*b*je$XF9%!5-5DP2Iy8qdqlpVm03d2`i;H8F6j(cL?Gy^!JXbVU`Y2RM$ln=lj zZMdC3GEqSOIeGeBuQhG1)~>zQq%T@ZS^*5EAeRJa6!rqiwXI+cpx$`z7(rF;l$Su+`Fa~J;7>;6ME><#JFcua zH8!gdge_rNNjQ`b+w!cV|9a&78oenoN(91I`Px3KdQhz*@A;Jz@Kp3SAMa#DKx(df zspi;W)w`EMLo;WL_)Q?hLS6#|Tl~XZqlwq;V0l{u66w!4N(8aJOwqy7!xk@XXn51z zR?(mBe^ZGQ(B#ds8V<&W!N5B8=F|1WrMKu9EjEZJOznZk@|`9yuP^tb`3FqIOEF%c z3kEa{To%E-Po-q4dfjkT^knBgi&xA%Z@Ig@=Ku8hGbghlJ9rA?8GaeMo^;F|%+c^C zgl>a=vJlP(YCoU-i3xrSI}|zl+f96T-d*k+_ALhSYhF*au6FX|9Y6{s-J^%O7J?V=>Gw@zd-L=-Run%T!K!@{P(s_H$y zOm0J;HBXqBtZ`$k$DiNoVRoR9g8a*VE=|h^N^H3-G39Y8@}KXyCF^!_Fe#Q!y?yWE z`2tu*Arygp+uQw;89!eN@=H-3Rn65>5yp8NhVX-QwTr4(Kww1kd^Xyz9YpPCJi9Zr zhKKE$vl)jNimhelAO2*&Qq6p~nzy3`VhrGx1IZPteAq^C0I$LOdB&SicwO zv%WFL1gZX=C5u{xG29Q~b(n4JH$8SmNQI>1_Dz zTD@5FD;F#ig$6(O7X{YwemTzy&gh`H$-T;XPrPf6ia)+k4SO?2P5%3|Ius#}tqWh| z+!%eUlQdVSCl15ePjiSQH(z}X}ganLta>oJEsKND@H26aHl+;FoVyiV6gLu1OJ(^C; z-bqbuT@B<99z(4(VbOqRAu6%sIbV`=%O%F)%Rc1n;zR*|#pOozg23RT5MsJxm-;{J zS+3)9QV`Y{#KW1RcmNzm6fO@ZsXSMMeF_S~%-~;d!L4H#Nv6ii5P`Rv7z-cuB-oIV zuZoh!ISt*E?~1gPBk+4#&bD80G58;urNrP>Bui%K*=nh;O)qEV78e zjF>$=Fdo~NH_PRBC*;Zx0@hc&)k2V!j-8lR6IY}C!EgT%2Etp#2&89~!S2<7sNZh+S!`?$i`x3*M z$kb{wv0D!xZZKO|oF=Hv8D12G$T8{;p*}wfwF&#n4DrEU2{6zYeF0{FaPryiiI`Cr z>hQ4QanSkf7zDHK4Y!cDO*er0U*d?DA}-E|KG8l%QdzpkNPEG@UxE=Ibk&d^vjr&y zG$wGI{I=#nMEwXYfE3XIVJqaHeoPR>hr~J%Ud7N3bXvY>`h(QaX~^Lhs9n92n;}kvM$C1Trqx~CYhO>V}Y!nr)XPb3*`?Fx9n|!1S4c6@weWB`lI-x`fX-% z^uK~lNl(<_7#rH7`L#l%l8lgem*qvc89+0jovV>S%@D=qUrR?TEZ10>2;zsm zFZrI*4CEJ~`y}~1am2Ms2tNpMF6QpA@(Diz-1q>a%VCiEj2%!tzrW4A>!(KJCR!yoxMO-7~Nbm>Y+8Gn*2x3 zd=mSD#2}vc#8RJe2x6jXCsW0@Nab)1_u`(sSW`<4ed1Ze!pG_rVFx7pAK0-}x>w7T z_|qWpp9sUt9En2HhmT*qdJ9W#e7ECay~~t21fqlJ zzd2|70~7$sbpV6%oMPem-*AuBBetEC#Kd20xqm-2g%V%~u0&3Zz_e3p-0JK^jW(1( zpK98c{NJtAy6H=1k37e$*oq64vA0}k7;p#me!&))=M#m5`}qyIoT``WE`}r0!em|V zX%3wGVDK^EOaZ=MM?+K1L*5tZD;k9ukAenfN(+PWw^^R<`&`zhq|n68!d}yNK;U+* z+NHWPb#^Al-QCQ5k@P}*SRE~-eJVgDH@+J0HNEI)LItwIWWjY8QI;<nJogJybU1X{@~h3X zwv6+M12nnTW)b)h2-{h&`? z6$u$x!eQ48Gz1^V_DCB3wRAI+&zcUxEuEh)b+U08BX$Mj1OEX%BPc6*05#f4{u$-K z;5W}`GPDJ;e5{+7sB*tV5(^KF>vfa?>e_W!pgX>*-n0-)mMRX$#TgVZ(aHg7SUoOr zN#|GkNgAhxt-V?HWz&jeX)Zi0(oW&*W_Y@M>?t6}RSnzVnxO2o`5lHF7 zm#P%ZUAT~|)zr-_bQhKRceaO|ePE^`>I9E@1Xzxg+{9LBJ<~W*<<9OZFekBCMNZDC zYp**LPASU|mct<>0f=M`8pw0so3KJSOh)QEFKWmcPCHf^^aOhJ&}3*enqKIq=yit6 zW?0-u#^?BDC6V|XknoJF=>uYY+7lHj^L^WScS*oNi_h*6P;u=1bA-@A6PMfNctJP9 z6G?~r*7=yAkTTP;D}cBTN{4J5HKE$X=+`Ot`04y)WHHlp@MJ{z+D2afM>*;2V_hwJ zV{5OS!{3`6+6{ko&Fzb;Ba&1w?ib$MG1zb>d*bU;;q%>zpW?stBIU4Fd`lSv^7C0D zea&oNBDnGU9oZyqY+%?Z8mukb#k!pXvs=QO5p>f~tHio};pX1fbig9HM(4M6DPgtEhdx@$>%|#jl%f z9l02p@zhkm%HDmQI6dBJ-D^imQt?Tn>gAe?!WE zy)OzKP!y>SrT^VtJ%Q?7n)@>z1_-3G(@nl;E;NHgt{~Bwn%R)G<@kq9(X_7bM+VYU za%dmO60EDko&DxYyZm-$D<4^&oQ*vO=O>lCp?Dt ze3lB4X*-&;X`>j_5QpJolj0lw7X5VH<+$}HWevd`6WbLeX|?>t*GkP)jVXX0(>7RE z6nvR!u_Zd|kC-=}cfW1%v~b;7YRz6gIADPUn)wwgeiA}n$7IuppORuXlfyoq`%7|GyU-87D^~(HWH!{`fg+`eruftLhGq&Ym4{;DI3I_Z1PFXpu-TU0$v(=lS(dbaif@|Av#%O{-lORVNCJzt&<{#bc=(-k1 z61{!C(tpkMG`9?*qD*e~nPc$zE0k^P5sHpY4E=Ay#CAT*UMrbZ{C_1MlU~g_!GM{~ zII(4VXstnb&tm0BOIg2uI1^KsM~B|06bz89i6L~YFWb+~yXy$8 zg!e5Qk&kh>Kr9EdoU1tsNv-C#m{NS7MzkUnkPKQSnwxKbW;4O+xAsRvP*`Z=lBc@f zeus!L4GF=2gv+Q7i^u2zld=yVzj;p%87q^j`z=cHX6pm!x!I~@;H=TSIbFcbu-*hZ z?}?A)9zh?2Sa(&a0*Da9*6CpHNdaqinTrAhw9i5fvF6*Fmh+7fIh$$2d`t}G6c)|Z z6eUFQ&Er0NV?R5+?8BRyy3CgA+4&i8h|VT}#R&$)8O2es{sXR4VgZZ4Omx+J8Mry2 zLf&tNOh_NGqR3-}E9>Cd`t7tPU&+Rv!wcikU3*JWjJZwOeteY!slg+{>k(Y-!c`SH zhuDy9xleo$O8{I*<$r0GHpUL6B+mF)_d=!R!MoPvpXx-nV$ZtP0V|#OP zG*Hd6@d>^?Do~}5Zc>IFVyNxbkg8uh@8MXeLeO*)SNUlDYH4(tCsXd$ z`}~m^Dk+-HWxO^1^i+q>!LAT4r^5nCNA!bUO)^NbpBm&=hb5^Dk24QVeTOeliLjZN zV}hTi5Cly=c%6SLRGi&-7$4z+jqDpb!I*oZtCO&x)f!gba%H_)jhF5F#422BF}J=9I`RqaY}QO0S&HS40t;V=}t6-5!^8dp{ZTJq#uhc6@79bSDg!b3<{e zyQ_H&Sa&WvHu9uqt`KLNQGhhjT`nW##X9_+Y#;(NqC99w>MZ~97#?|eM1&6Lmjdj^ zE(du(3Q&6p0<>~*vWvwx>AnK0wY5}d{Ewg=mHCg0tX z2N@tNaTu>HGIA2ajjLVic$>FSI)Y-ak2{$Wj@bAjOqOf*DVwfX(>}#aPRWmnOuk-> zlliv}w4gur6#meALILWOxufl0B#;v zIsf@_g80hhY5{+{EsoT*r zwmaU_m;~`(SddVV?DOi6OMXgR5D6kN&7BUx(ETHHD1+a{SGfc~PR&|tH&sZn#z5wa9);hkg?As@4VD9cvD6+( zg6Mv5@v6>VvKQRGXt|jxPrQn8*1^d``5!w?3?%sN$$sjFaq2RFXUJ2d?X z`M$c#9Z$eGz4dE>${Ux2A0Oo~^kaMG1i|dJx!1olI3z+~iN}Z|uJ%mg8!I)K8!|NH z=67|p`A=UYX7~Ij|MD4gt}Amq^|z8#a_6QTEJDNXOPUQbf9ePz{%p77*k0+yeI=)s-_6j04IW*>^Q&EPBqt|S(A3rOeVfx^dHYML`L z0nwoCU1ePYNRXLU0ayF6x`dZkXaR)#GeZ4fe*k!1QDJa}_31XB@70+A9Npwr$n7M= z{I#t4`&|3&ASaKiva-{<1vwkv&SJBYWCZ2UDQXiwaNM=m?q$Z0lKnG7Y&Z{v5R>Sa z_sUMn=>rL@(@%4s9q9Zd=<4cbc9Oc>z) z^B1XHOfp(MWft5tR_k_mT?59MWFLGnQ-r~y85~viw}Rgf1x!3|_`X3N?D7U8ui0Yi zRCwF>*!S0VQppvWGCz3Y+t5WvtfyyuR#+@GllglNj+gI+j(W(i-e>cbaPbtrOQr1M zhT~sx9k#B_7efN?P9plJgzOpO#)@3DL*}*r{sEC1-fXM4fTeS8h&ioVQBYiZT+lL@T=4IK!s{5 zcv}D6W=hyC4r*_^_u?D}+cSA9-0z!-_VY)YU9Ps>oi(#EL}iZxi0enR zC(ts3Zvo=0N$$YPAO@d9qfo+^Zw_<^tF8dqQ5|R-cUrwb0)4fUT&~N@`JCIBOnjN# zNXt=C@fin)wZG+cUGVgG{fU&+c^Z@LW~Nc^i4^i=mRDPt0YF=)-zfC_75MjWTx{>1 zWLwNQE*E=TD#}N@`_I`d7~(&GB{&9ymK_{C|1O16E}>WS66~1CIW|83Q4(=C&+?_GBd0Ffw&$$RBHl zR+lbKBuF|D@vCw5M@fv&+NlPYQG_@3e2+&qb>+L29HE4S1pX|)tDKDde8Jw$EMIr4 z>3zLLdVa%&*^##PukZgHF(~Bl?o3JhqbE1zP)L}uyrV&0Y{kS!oz)X990T8@wQQ{D z1Ab}PEn_YwsG!*diETFDN}^k5*TorO!r=QaEN{QIb? zUF`Xb-1eA^P%|f_S_uWjA8(cLY8s8$%=TtF`kL;r+dVW*XX4c)(P@)X z;%hp>j8?rx9;bjtc*Y*pdpEE&RCskpc{jgB4buzJGE8p%eUh45eGQ@p<(%rf5@Q9x zWMqEiNLX;u^KO4}_z(2;Q{o>-K}ne<6^tVTQjw{@vo!Ozc+B0J&*p5#Eq-QP zbG7*q8bhX_i!cF~RhjHI_>UY_fAYAGu*CY!6B7JAXwWNA_OiivwQ66@4kee?LTDIS zqz7Sj4-07kVsj)jLEwvT;aLLgkRM#P@7Z?kngs}jJ^?tzI!yfhFmkJ!_TSdLP-}io z4OhTKFS5>F?l7rMQaKJhJ5e~IKlR0=Sq{~YH}smT0oA+``Urtft~_H?-n#Vr$BXhe zKtU#2XIGE&f$Lj?Aa1}`+7j>i_j57L#mYq{p0K|<^L0F7mV{smwbuiXSA(?T;bvNk zw)5Gy>wgP{L{4@m?D!m(7O=%3U=HxXmtPiI6;Z9lTGkPAgp|YSkDq{#=*Y{R&oGxx z9-r}=(wKA%+4*1onU=u9eLSCn7>DHtUbj5=>TpKVppb5Q&F<+yq_2?!`Hm zWftB8h#jKmA8km(!^0i7*$bbf)w0_>!{`fxD*jNQC1@K@A)h&2J<~bR^cfzlUIsK+ zKZk5UYE^q_RNn4FWXc!6+P|VmXaq}xmkqpH5Ux&CooBzIi0hll0~YH?#(E8WPxgW~ zilUaW4spQAuTJhDmV-@-`m@_M$z#d7ZqkSB_gWCS+HfL%DdYbA=GiJ$(-rs2DQ$L%F?I|zM+g& zULs78&hC7JlS$@q=J?H?P)#};WG6Faaspt?@WA(F6lH}So#dhA|gDE~bCBzHfCq`l#7#OgXL{gPO$9&#_3O&aS_z;jOar?^&61hEeZ11Ajg? z3(hv#d))~IQxNGTBPt7ifW_P=;<&t+5D9&k>Z#*srwGQVGj|ZkRqcDo@35G*sDXX7 zFQD<_)$gxbCmRzR=EL5iFJGE-2iSqatp!_v+^?6_EC7UlHZTScGaG+l=OTPT00hIr zvDEx3P^|slh9*h)=wfZwsj_m`eo)&{tG}O^!?BqUW927&*(M{Q+Kv=Tf;pOhBT_64 zV~lNAEsVxl_PPGK3{2%9qTd5#6ZBlf{9WPju@(ek17Xe+y}F7RS+-g#-68^M4e37m zRve9Q7|Ae{c(wjlCiJCFlg~4rLUm!>u|e<)G7tS>G)|D0+m4<8VcRv8gk{{HYIp47 zVsek=QvKTEVKjWv7YY5^3}vklilW1AeJscBmIDY*fB9zs60jcrMIW2v;-nYNnnPR0 z^I=RZ5Cneo_97R2bcKgI5=w);jyt_e(N#9})I54Iwzl$gB2Na6F{&47g~vF&<0;6g z7271|=G=a<-fZ0ez)SaGrk$Ap4-$Yk()49|=ziU+ zcR_0P%wxquLlDo7dNumKmg_dM(u?Pk&$TY-vAb0Of%2C>65wMG-cl&+5Gc5_rt8C6KawW0y6y z`6a5a=SRa4!*{EtmRW3YYRAxyJ6`DuDYbPD(<^cq%ZpmAY6k92Q@@E<1;4q$vvRQ8 zmF*vIs*`CF*wAyklPSxOrD2<4vc)boIkk3NZ*2syNdpme(35`}`hI5GYphY#QZPxY zNwcpW{zdKR@BGVifr!;8+0<;EZ{=SAonbzZ*zR6SSXs%fUQ+2I)jw#C{^WC4HA(j< zUt3s#J-E(lmZ7Y*gZERK3_)16F@g5t;*xW7=wFZ()}xzCl&mf+4TXpYS`!GgIj@?+;vftR_Sl zvMu`|B@&JASBL3G?r})WC>$Ls$ zEMkhm|Mi#j-UVgY>V0byj|-p%(J}&y-I2;MqEoILHAQbZvKIudkyCYIMI!-aayPY5QixbP>I5+~O6* zD*4O8Ra+`eFS8D(K0jwPFTcNWWGV5NN@7PzTA-9_{+*n@CY9d^`U!%i_$zREHXP4q z>fk3=yRSBZnM3@?%MZq~Y3EA`3*1Ka?AcCpBe+EPnVC-(MEV511e6MX1PSwRx!_O*+5X(}&b= zDqM^tB&aZGu@I*Xk&&_0uLpowLy4!FF`7c>FT|>8x6iiz=FYb*o0I)^sOPoHZZw)aKVNM(X}|ba%?kS^g`ElVV0a;0 z5hJ>R>~dNYo1vzsfD(#t>?gl-2%XWu4uZzh(b1(}{?RZ{1O(T;q_)RRr1j)C&%Ab~ zzW*$5+}M7N`H(B^CVg*V;Q>wzE+kc1aEW$^=r< z^MVaiSNAc{w3@Ftq5Vmj?@Ts&_iwv-WplY2E~*lrMo_sKZ!p912cEEh#S9EW99HDU zh+eTYe=MOSjDoh4ju~4>m^SFN>>ZGjA-@xX!Oi-MUU8?}QxSLj*9VZr0%POg17&F7 z$Yyu=Xw3IoQvoq<-ESekF{h<@(g|wW9N6*(OPVH+pAMW2Dz^{*w zoA4@n+jf7Z?>u@$Ip4ojvx~H|by zw(!PO>uH8Z7&P;&NAqF)3mzs{tG(}<9(*u>b)E0?c#VK4sfC^mdxc%r*(GN?U$LiR z;XWJ!nbLqgv*_(@$%$84xmpf&uRovM?MuKi7gI;eto=U$RqBww&*-y)hp@odTGxT9 z#Ce$opIkF7wlLY8$v$RD4^7P@0NA!oR+ZN7mo7xvN9aT`Vmdq58C@Kv(*;=OAc(L> z85_!;+s)NH|k7P2L2ZR@TqviwgQf%Z-Bqj<~^CG%?Ni(Ub z?;2LC2J07`ufJDE+1(8cRU@7a+I%ZYYv5ExG+yuHrQiIUI2Yj<6r81=7}JygI_H8N zFoQ(4aJ64p%Y{bo6;hkD8ExQzciVM!M|`38`%XOzuU7iIgG zab}mLPv22ex$cc~7TV3fjewa7fhijJz41R%EUNQm;6RuR=j*J}=3FiGm!iLk-CndZ zV?_O+_$ZNaNPBd?9I?$XmY34*macvh)h`Wku4gOSB~9`*0HnL)z@eL4te#n`p7Vhe zgWj9tK_xezwu(n4yI%U!J0|Wg#}(=LQCK9R4a%^#N{$`6`i~XT)N3`m!`bZ>w>IT5 zB2!hRw*Ud-9IO|zZ8X`OTJRh}9Ayf*Uwwe7 zlPgOpnt3~#YtVr#eSY^+WyK|Ay5NF8*YLaa{%C7yR_d)Pg<$YS(ViNrr~G^QdDRu3 zQuIB9nhd-gRlh{4qp6uFDUJmWBYHWniXZbO@k$0@x3?MZk7<K^fQ|i7`mQ;I_5TR0i4^y5;){@Y0p>v^lFD`jQafkw$Sg4KxcG+ZpM_o2E0R z5g?QK+l`_koV;u=XDptdkW~ecXJ;*5z@CxO?~`S}^F^zn3<5)nfSZ-va0EW``^7>= zhI@S!^%#NpTN4G>mCc50sp8{5#i%lIOFEz<+ttMnwAS3SMye>oW$|}df)EHQt}CIf zE1NPUsyKjN`};7D=%t1{eFxCgbc-biCnhEu_Y1sDUpL((GLOKEKy02~A@CII3In5bLuK1U}=nUaB&c!H&{rEN*U?brMBafBOOr7{X`QluEN zA9{+Pk%mxqahOxbor-v!Aw6zCZz{1!$_gbktf|qp3GHXD6)N1KJVnVQFRRSi)o$DN#V6t|^_2V;dWjF|1yg}g(4-hSfWNx})@`nb zVPi|GtE;c(~m_Wc8DLKt9>DvKlcCwC%?OJBC@z4)piF=uZ9$TgU8gIT;qR z`26|vcd9t<>Z%epNad}H|Bg<`ay%=_l`@VJvq#ORY%(K4W&55FE(%6g|3vEDMGGAW z&QactK@Np+N%qm4=8Y%A&ASZ^Gm@ZLP~GJECn6If#voueNlgGSWCCcGFOc|cUP=Ve zqG$afMWtVX(J?*rk2ippZcsDfM_qB4Y##!fC4rFUO`VCUBaN!SO4}?ggg#& zhyWGQ{--!ekh|m2(kp30t;co7lg0T;Vwc}=swWrEQrvM_I(Iy|RXgV${2RV{Mwk=A zSn^o=xTjvTRR}1BrM`=TdIk>*`|foFqJbnM)wAyT+a4T7u@W1~;GOTHY&6CMlXYUX zOeFT0Z1ef(^3V*Fi0Iz{)N`)xG2Pzg;j%A1)tr1anWaUj8Y5!_%roIw>uoz+Q zee{S3fWr@f3Yb10fnR4g@lBc81S3B@&iwEdZaE+frfxafNH5_W^vV`L04x6-9)1eS znGxD=YW`?sWMpF;j)#y63YPpc{6yGxXQ1#2e=qeVeaFwAVU(g92JjVz`G7irnmY4nJ>6gKl%*eN1 zq~_7XT>TEt(~EF^v)ng;xK5!YkV^#pBq#CezQ?xBxc+cli5mT@ntr-mC;k0U(f&(P zBjC)46PDaSD?D;xs>N*k!8{*Ns+GNB423DAh2P$1wI_xxAJ`|qcCi6!RrGu2;Y&F+ z(|qCMnQj40;1JGC>-k}zEj$GB#srU!SAM`_XODkVlhVPdd8IWH&T%fi|qAir5O12szJ@$uKf;wNm@n{m~4DHVu*GSG`Sjb zg=}m3S;U8{q4_?@qj~=GUbi5e0L zvayy_pJH8b+~akV8r8vW#rQ>BR`jD49+SB7GkyP4zq7fvQ;s{yft-7-U*r7vo^aE_ zSYxZ#`Ql3m&i;AvAKdK=`o?)7eaGLQ>EDIRll4rLuC~g+Y_k2#|6P1lO!LDwv;)pT zfx@l%0Kr+h~VS+;9lX=8yC)#V`m{P%6cjj6{bnJ2|1WCXqaAb zb4hUZs{(k4)bMG*V^BfoDPEZ#u{ExE*a%hKWz@8OY9?uREYmQNz5}N{B(6st&93Ta z$7^(R@jBxB+bF2h@b|2Tzi9;4gkAFTOn78O|BZ6M)7vr2O=Nph7qc2n2UdqoP?39* zIk*74Y?xA8daJ)he6L!+_gPCbuJ}jD*Q@Emp7KD$K7pKLFq{Vk^U~9uV{-0tVoRSG z5%#Rc_hZWyvv%gl5+bK8QU7jz*flXiWV#UuS+zR&-Qgi>~ZcmN8%48PmLxx6goQO*;wpaNtGe3w8>&R*3L?&Un(l^?-w@Y+h#=$=7LDzZj>cqLT3*W`&}5 z;7oZh{zL5GhEU^lXZghh^<@NtJ0Q)j@n#b{iyOfHST%MM_a9N-*KH;+j#Q5rzHSC5DU@PEO zgtaKvy{Q0q3^8IF8?W-UUfd9hX(gYM2LMIK58$l6v_ja3*tKou6WV@kpcfrus=levh#Olc4Z@)W4sezxx1daGk`Pb^yXxJ1NIK`^22 zU2mBg_D$)UOAuBNh?BlMEt5D>D`eUE-M&$%+u$qI5YyL;m8Rf$!)e-2DC9!k_4(kN zy_$*520|Zi;$}+mI2@r9x`%+^t1KXKHr2F!#t4?Y;6D-^LHqh!y9A>Uq9tbbQQRA_ zmG>Yby_tgYkvl|^2Mp6#Ak#$8Yp~be{TCxw^hFaGy#B>86GntbvwSt>XY}eCLWD&N{5g z_wB+QcGM2P3o9|q73Y#I9t z^yyqpM(A249Q99pTXKF#;<&{@UaJ!?O`E{$0UyRw%8=g)^8N+*ye(b#

x^jT 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%0Ascwe?*ZZcgb^Gm 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