Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5b8dd57
feat(workflow-compiling-service): export a workflow as a standalone P…
kz930 Sep 1, 2026
a7558eb
test(workflow-compiling-service): run an operator both ways and compa…
kz930 Sep 1, 2026
cba725f
test(workflow-compiling-service): verify a generated script against t…
kz930 Sep 1, 2026
97e05ec
Merge remote-tracking branch 'upstream/main' into feat/standalone-sou…
kz930 Sep 2, 2026
41724ff
feat(workflow-operator): export the source operators as Python
kz930 Sep 2, 2026
bc8fc17
ci: give the verify spec a job with an interpreter, and keep it out o…
kz930 Sep 2, 2026
9be0dbd
Merge remote-tracking branch 'myfork/feat/standalone-verify-harness' …
kz930 Sep 2, 2026
883ea72
test(verify): pin the no-generator case to an operator that can never…
kz930 Sep 2, 2026
5a44c3c
chore: leave the harness to the change that introduces it
kz930 Sep 2, 2026
3f07020
Merge upstream/main
kz930 Sep 2, 2026
df10bd8
chore: leave the two verify files to #8327 as well
kz930 Sep 2, 2026
73375b9
fix(operator): slice the raw lines before converting them
kz930 Sep 9, 2026
cb23c1e
fix(operator): read a CSV the way the parser does, not the way pandas…
kz930 Sep 10, 2026
c2e2eb3
fix(operator): take the row's first string field as the file to scan
kz930 Sep 10, 2026
1c0405f
fix(operator): decode a fetched body by replacement, as the executor …
kz930 Sep 10, 2026
208816d
fix(operator): slice a JSONL file's lines before parsing them
kz930 Sep 10, 2026
566aa15
fix(operator): take a CSV's column names from the schema
kz930 Sep 10, 2026
3d12fae
test(operator): assert the column names the CSV reader now writes
kz930 Sep 10, 2026
14b5571
Merge remote-tracking branch 'upstream/main' into HEAD
kz930 Sep 11, 2026
832b914
Merge branch 'main' into feat/standalone-sources
kz930 Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,33 @@
package org.apache.texera.amber.operator.source.fetcher

import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle
import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
import org.apache.texera.amber.core.executor.OpExecWithClassName
import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow.{OutputPort, PhysicalOp, SchemaPropagationFunc}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.operator.source.SourceOperatorDescriptor
import org.apache.texera.amber.util.JSONUtils.objectMapper

class URLFetcherOpDesc extends SourceOperatorDescriptor {
class URLFetcherOpDesc extends SourceOperatorDescriptor with StandaloneCodeGenerator {

// No `pattern`: the reader is `java.net.URL`, which asks only that the value carry
// a scheme its JVM has a handler for. That is not something a regex can state --
// one excluding `www.example.com` would still pass `htp://x`, so it would advertise
// a validation the field does not have. `examples` offers a realistic value without
// claiming to constrain anything.
@JsonProperty(required = true)
@JsonSchemaTitle("URL")
@JsonPropertyDescription(
"Only accepts standard URL format"
)
@JsonSchemaInject(json = """
{
"examples": ["https://example.com"]
}
""")
var url: String = _

@JsonProperty(required = true)
Expand Down Expand Up @@ -87,4 +98,38 @@ class URLFetcherOpDesc extends SourceOperatorDescriptor {
outputPorts = List(OutputPort())
)

// The generated snippet uses `urllib.request`, which the translator's shared
// imports don't include. Following the per-operator convention (e.g. Split
// emits `import numpy as np`), the code block prepends its own import so the
// generated script is self-contained.
override def generateStandaloneCode(): String = {
val urlLiteral = objectMapper.writeValueAsString(url)
val isUtf8 = decodingMethod == DecodingMethod.UTF_8
// IOUtils.toString decodes through a reader that substitutes U+FFFD for a malformed
// byte, so the executor returns a string for any body at all. Python's decode raises
// instead, which turned a response the executor reads into a failed export.
val valueExpr =
if (isUtf8) """_content.decode("utf-8", errors="replace")""" else "_content"
val buf = scala.collection.mutable.ArrayBuffer[String]()
buf += "import http.client"
buf += "import urllib.request"
buf += s"_url = $urlLiteral"
// Catch the fetch failures, not everything: the executor guards only the fetch, so
// a value with no scheme stops it, and `except Exception` here would swallow that
// and hand back a row instead. The two are told apart by type -- a fetch raises
// OSError (URLError, HTTPError, TimeoutError) or HTTPException on a malformed
// response, a missing scheme raises ValueError.
// Still divergent, and not fixable this way: a scheme that merely is not RECOGNISED
// ("htp://x") stops the executor but reaches Python as URLError, and the two
// languages do not recognise the same schemes anyway -- Java takes `mailto:`,
// urlopen does not -- so no list of schemes is right on both sides.
buf += "try:"
buf += " with urllib.request.urlopen(_url) as _resp:"
buf += " _content = _resp.read()"
buf += "except (OSError, http.client.HTTPException):"
buf += """ _content = f"Fetch failed for URL: {_url}".encode("utf-8")"""
buf += s"""out1df = pd.DataFrame({"URL content": [$valueExpr]})"""
buf.mkString("\n")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.tuple.Schema
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
import org.apache.texera.amber.util.ArrowUtils
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.apache.arrow.memory.RootAllocator
import org.apache.arrow.vector.ipc.ArrowFileReader
Expand All @@ -38,10 +40,31 @@ import java.nio.file.{Files, StandardOpenOption}
import scala.util.Using

@JsonIgnoreProperties(value = Array("fileEncoding"))
class ArrowSourceOpDesc extends ScanSourceOpDesc {
class ArrowSourceOpDesc extends ScanSourceOpDesc with StandaloneCodeGenerator {

fileTypeName = Option("Arrow")

override def generateStandaloneCode(): String = {
val basename = sourceBasename(fileName.getOrElse(""))
val read = s"""out1df = pd.read_feather(${pyStringLiteral(basename)})"""
// A timestamp column needs nothing here. The file names UTC and holds the
// wall clock as UTC, so pd.read_feather and the executor read the same
// reading off it — no zone of the reader's own enters either side. The other
// scan sources have to name their date columns, CSV and JSONL carrying no
// types to go on, but Arrow states its own.
//
// The executor drops `offset` rows and then takes `limit` of them. Feather has
// no row-range read, so the same window is taken once the frame is in memory.
val window = (offset, limit) match {
case (Some(o), Some(l)) => Some(s"$o:${o + l}")
case (Some(o), None) => Some(s"$o:")
case (None, Some(l)) => Some(s":$l")
case _ => None
}
(read +: window.map(w => s"out1df = out1df.iloc[$w].reset_index(drop=True)").toSeq)
.mkString("\n")
}

@throws[IOException]
override def getPhysicalOp(
workflowId: WorkflowIdentity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,28 @@ import org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
import org.apache.texera.amber.operator.source.scan.csv.CSVScanSourceOpExec
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

import java.io.{IOException, InputStreamReader}
import java.net.URI
import scala.util.Try

class CSVScanSourceOpDesc extends ScanSourceOpDesc {
class CSVScanSourceOpDesc extends ScanSourceOpDesc with StandaloneCodeGenerator {

// One character: every reader narrows this with charAt(0), because univocity's
// setDelimiter and scala-csv's DefaultCSVFormat both take a Char.
//
// `examples` names a delimiter the fixture's rows do not contain, so the
// verification config generator does not pick one that parses them ragged.
@JsonProperty(defaultValue = ",")
@JsonSchemaTitle("Delimiter")
@JsonPropertyDescription("single character separating the fields on each line")
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonSchemaInject(json = """{ "maxLength": 1 }""")
@JsonSchemaInject(json = """{ "maxLength": 1, "examples": [","] }""")
var customDelimiter: Option[String] = None

@JsonProperty(defaultValue = "true")
Expand Down Expand Up @@ -145,4 +151,77 @@ class CSVScanSourceOpDesc extends ScanSourceOpDesc {

}

override def generateStandaloneCode(): String = {
// Strip to just the basename. The standalone script assumes the CSV
// lives in the same directory as the script (Texera's resolved URIs
// can't be used directly outside the system).
val basename = sourceBasename(fileName.getOrElse(""))

// Resolve the delimiter the same way the parser above does — first character, empty
// means comma — and escape it. Every value the field accepts has to survive this:
// pandas reads a separator longer than one character as a REGULAR EXPRESSION, and a
// backslash spliced raw produced `sep="\"`, which is not valid Python at all.
val sep = customDelimiter.filter(_.nonEmpty).getOrElse(",").charAt(0).toString
// Texera's encoding enum uses values like UTF_8; pandas expects utf-8.
val encoding = fileEncoding.toString.replace("_", "-").toLowerCase
val headerArg = if (hasHeader) "0" else "None"

val args = scala.collection.mutable.ArrayBuffer[String]()
args += s"""filepath_or_buffer=${pyStringLiteral(basename)}"""
args += s"sep=${pyStringLiteral(sep)}"
args += s"""encoding=${pyStringLiteral(encoding)}"""
args += s"header=$headerArg"

// The parser above sets no null value, so only an empty field is null and every other
// text stands for itself. pandas instead reads a list of words as missing by default,
// "NA" and "null" among them, which turned a column holding the country code NA into
// nulls. Both halves are needed: dropping the default list stops the words, and naming
// the empty string keeps the blank cell null.
args += "keep_default_na=False"
args += """na_values=[""]"""

// A CSV carries no types, so both readers infer, and they do not infer
// alike: the schema above tries TIMESTAMP and parses what it can, while
// pd.read_csv leaves a date column as text. Name the columns this operator
// decided were timestamps so pandas parses the same ones — by position when
// there is no header, the frame's columns having no names until the rename
// below. A schema that cannot be read (an unresolved file) leaves the
// argument off rather than failing the export.
val dateColumns: Seq[String] =
Try(sourceSchema()).toOption.toSeq.flatMap(
Comment thread
kz930 marked this conversation as resolved.
_.getAttributes.zipWithIndex
.filter(_._1.getType == AttributeType.TIMESTAMP)
.map { case (a, i) => if (hasHeader) pyStringLiteral(a.getName) else i.toString }
)
if (dateColumns.nonEmpty) args += s"parse_dates=[${dateColumns.mkString(", ")}]"

offset.foreach { o =>
// With a header, skip offset rows after row 0; without, skip offset rows from the start.
if (hasHeader) args += s"skiprows=range(1, ${o + 1})"
else args += s"skiprows=$o"
}
limit.foreach(l => args += s"nrows=$l")

val readCall = s"out1df = pd.read_csv(${args.mkString(", ")})"
Comment thread
kz930 marked this conversation as resolved.

// The schema's own names, which every downstream operator was configured
// against. They differ from pandas' in both directions: a blank header is
// `column-2` here and `Unnamed: 1` there, and a header the user really did
// spell `Unnamed: 1` is kept. Matching the placeholder against the index
// cannot tell those two apart when they coincide; taking the names by
// position can.
val schemaNames: Seq[String] =
Try(sourceSchema()).toOption.toSeq
.flatMap(_.getAttributes.map(a => pyStringLiteral(a.getName)))

if (schemaNames.nonEmpty)
s"""$readCall
|out1df.columns = [${schemaNames.mkString(", ")}]""".stripMargin
else if (hasHeader) readCall
else {
// Unresolved file: fall back to Texera's headerless naming.
s"""$readCall
|out1df.columns = [f"column-{i + 1}" for i in range(len(out1df.columns))]""".stripMargin
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ import org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

import java.io.IOException
import java.net.URI

class ParallelCSVScanSourceOpDesc extends ScanSourceOpDesc {
class ParallelCSVScanSourceOpDesc extends ScanSourceOpDesc with StandaloneCodeGenerator {

// One character -- see CSVScanSourceOpDesc.
@JsonProperty(defaultValue = ",")
@JsonSchemaTitle("Delimiter")
@JsonPropertyDescription("single character separating the fields on each line")
@JsonDeserialize(contentAs = classOf[java.lang.String])
@JsonSchemaInject(json = """{ "maxLength": 1 }""")
@JsonSchemaInject(json = """{ "maxLength": 1, "examples": [","] }""")
var customDelimiter: Option[String] = None

@JsonProperty(defaultValue = "true")
Expand Down Expand Up @@ -80,6 +82,35 @@ class ParallelCSVScanSourceOpDesc extends ScanSourceOpDesc {
)
}

override def generateStandaloneCode(): String = {
val basename = sourceBasename(fileName.getOrElse(""))
// First character, empty means comma — the same resolution the reader below does —
// and escaped, so every value the field accepts survives being spliced into Python.
// See CSVScanSourceOpDesc for what handing pandas the raw value did.
val sep = customDelimiter.filter(_.nonEmpty).getOrElse(",").charAt(0).toString
val encoding = fileEncoding.toString.replace("_", "-").toLowerCase
val headerArg = if (hasHeader) "0" else "None"

val args = scala.collection.mutable.ArrayBuffer[String]()
args += s"""filepath_or_buffer=${pyStringLiteral(basename)}"""
args += s"sep=${pyStringLiteral(sep)}"
args += s"""encoding=${pyStringLiteral(encoding)}"""
args += s"header=$headerArg"

offset.foreach { o =>
if (hasHeader) args += s"skiprows=range(1, ${o + 1})"
else args += s"skiprows=$o"
}
limit.foreach(l => args += s"nrows=$l")

val readCall = s"out1df = pd.read_csv(${args.mkString(", ")})"

if (hasHeader) readCall
else
s"""$readCall
|out1df.columns = [f"column-{i + 1}" for i in range(len(out1df.columns))]""".stripMargin
}

override def sourceSchema(): Schema = {
val delimiterChar = customDelimiter.filter(_.nonEmpty).getOrElse(",").charAt(0)
require(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,22 @@ import org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema}
import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity}
import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

import java.io.IOException
import java.net.URI
import scala.util.Try

class CSVOldScanSourceOpDesc extends ScanSourceOpDesc {
class CSVOldScanSourceOpDesc extends ScanSourceOpDesc with StandaloneCodeGenerator {

// One character -- see CSVScanSourceOpDesc.
@JsonProperty(defaultValue = ",")
@JsonSchemaTitle("Delimiter")
@JsonPropertyDescription("single character separating the fields on each line")
@JsonSchemaInject(json = """{ "maxLength": 1 }""")
@JsonSchemaInject(json = """{ "maxLength": 1, "examples": [","] }""")
var customDelimiter: Option[String] = Some(",")

@JsonProperty(defaultValue = "true")
Expand Down Expand Up @@ -76,6 +79,45 @@ class CSVOldScanSourceOpDesc extends ScanSourceOpDesc {
)
}

override def generateStandaloneCode(): String = {
val basename = sourceBasename(fileName.getOrElse(""))
// First character, empty means comma — the same resolution the reader below does —
// and escaped, so every value the field accepts survives being spliced into Python.
// See CSVScanSourceOpDesc for what handing pandas the raw value did.
val sep = customDelimiter.filter(_.nonEmpty).getOrElse(",").charAt(0).toString
val encoding = fileEncoding.toString.replace("_", "-").toLowerCase
val headerArg = if (hasHeader) "0" else "None"

val args = scala.collection.mutable.ArrayBuffer[String]()
args += s"""filepath_or_buffer=${pyStringLiteral(basename)}"""
args += s"sep=${pyStringLiteral(sep)}"
args += s"""encoding=${pyStringLiteral(encoding)}"""
args += s"header=$headerArg"

// Name the columns this operator inferred as timestamps, so pandas parses
// the same ones instead of leaving them as text. See CSVScanSourceOpDesc.
val dateColumns: Seq[String] =
Try(sourceSchema()).toOption.toSeq.flatMap(
_.getAttributes.zipWithIndex
.filter(_._1.getType == AttributeType.TIMESTAMP)
.map { case (a, i) => if (hasHeader) pyStringLiteral(a.getName) else i.toString }
)
if (dateColumns.nonEmpty) args += s"parse_dates=[${dateColumns.mkString(", ")}]"

offset.foreach { o =>
if (hasHeader) args += s"skiprows=range(1, ${o + 1})"
else args += s"skiprows=$o"
}
limit.foreach(l => args += s"nrows=$l")

val readCall = s"out1df = pd.read_csv(${args.mkString(", ")})"

if (hasHeader) readCall
else
s"""$readCall
|out1df.columns = [f"column-{i + 1}" for i in range(len(out1df.columns))]""".stripMargin
}

override def sourceSchema(): Schema = {
val delimiterChar = customDelimiter.filter(_.nonEmpty).getOrElse(",").charAt(0)
require(
Expand Down
Loading
Loading