This is an automated email from the ASF dual-hosted git repository.

robertwb pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new ccba2e3a90f Add YamlTransform to the Java SDK. (#34596)
ccba2e3a90f is described below

commit ccba2e3a90fb5fc746f1f8752f9e614ebec357bc
Author: Robert Bradshaw <[email protected]>
AuthorDate: Fri Apr 11 08:14:04 2025 -0700

    Add YamlTransform to the Java SDK. (#34596)
---
 build.gradle.kts                                   |   1 +
 .../extensions/python/PythonExternalTransform.java |   3 +
 sdks/java/extensions/yaml/build.gradle             |  39 ++++
 .../beam/sdk/extensions/yaml/YamlTransform.java    | 210 +++++++++++++++++++++
 .../beam/sdk/extensions/yaml/package-info.java     |  20 ++
 .../sdk/extensions/yaml/YamlTransformTest.java     | 143 ++++++++++++++
 settings.gradle.kts                                |   1 +
 7 files changed, 417 insertions(+)

diff --git a/build.gradle.kts b/build.gradle.kts
index b901715f085..9cd63258a4a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -303,6 +303,7 @@ tasks.register("javaPreCommit") {
   dependsOn(":sdks:java:extensions:sketching:build")
   dependsOn(":sdks:java:extensions:sorter:build")
   dependsOn(":sdks:java:extensions:timeseries:build")
+  dependsOn(":sdks:java:extensions:yaml:build")
   dependsOn(":sdks:java:extensions:zetasketch:build")
   dependsOn(":sdks:java:harness:build")
   dependsOn(":sdks:java:harness:jmh:build")
diff --git 
a/sdks/java/extensions/python/src/main/java/org/apache/beam/sdk/extensions/python/PythonExternalTransform.java
 
b/sdks/java/extensions/python/src/main/java/org/apache/beam/sdk/extensions/python/PythonExternalTransform.java
index 50782060d0f..fb44f827215 100644
--- 
a/sdks/java/extensions/python/src/main/java/org/apache/beam/sdk/extensions/python/PythonExternalTransform.java
+++ 
b/sdks/java/extensions/python/src/main/java/org/apache/beam/sdk/extensions/python/PythonExternalTransform.java
@@ -54,6 +54,7 @@ import org.apache.beam.sdk.util.ReleaseInfo;
 import org.apache.beam.sdk.util.construction.External;
 import org.apache.beam.sdk.values.PBegin;
 import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
 import org.apache.beam.sdk.values.PCollectionTuple;
 import org.apache.beam.sdk.values.PInput;
 import org.apache.beam.sdk.values.POutput;
@@ -586,6 +587,8 @@ public class PythonExternalTransform<InputT extends PInput, 
OutputT extends POut
       outputs = ((PCollection<?>) input).apply(transform);
     } else if (input instanceof PCollectionTuple) {
       outputs = ((PCollectionTuple) input).apply(transform);
+    } else if (input instanceof PCollectionRowTuple) {
+      outputs = ((PCollectionRowTuple) input).apply(transform);
     } else if (input instanceof PBegin) {
       outputs = ((PBegin) input).apply(transform);
     } else {
diff --git a/sdks/java/extensions/yaml/build.gradle 
b/sdks/java/extensions/yaml/build.gradle
new file mode 100644
index 00000000000..5b86d323922
--- /dev/null
+++ b/sdks/java/extensions/yaml/build.gradle
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+plugins { id 'org.apache.beam.module' }
+applyJavaNature( automaticModuleName: 'org.apache.beam.sdk.extensions.yaml')
+
+description = "Apache Beam :: SDKs :: Java :: Extensions :: Yaml"
+
+evaluationDependsOn(":sdks:java:core")
+
+dependencies {
+    implementation library.java.vendored_guava_32_1_2_jre
+    implementation project(path: ":sdks:java:core", configuration: "shadow")
+    implementation project(path: ":sdks:java:extensions:python")
+    testImplementation library.java.junit
+    testImplementation library.java.hamcrest
+    testImplementation project(":sdks:java:core").sourceSets.test.output
+    testImplementation project(path: ":runners:portability:java")
+    testRuntimeOnly library.java.slf4j_simple
+}
+
+tasks.test {
+    systemProperty "testRunner.jobEndpoint", 
System.getProperty("testRunner.jobEndpoint")
+}
diff --git 
a/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/YamlTransform.java
 
b/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/YamlTransform.java
new file mode 100644
index 00000000000..11245c4b4ca
--- /dev/null
+++ 
b/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/YamlTransform.java
@@ -0,0 +1,210 @@
+/*
+ * 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.beam.sdk.extensions.yaml;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.beam.sdk.extensions.python.PythonExternalTransform;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.values.PBegin;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.PInput;
+import org.apache.beam.sdk.values.POutput;
+import org.apache.beam.sdk.values.PValue;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Joiner;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Allows one to invoke <a 
href="https://beam.apache.org/documentation/sdks/yaml/";>Beam YAML</a>
+ * transforms from Java.
+ *
+ * <p>This leverages Beam's cross-langauge transforms. Although python is 
required to parse and
+ * expand the given transforms, the actual implementation may still be in Java.
+ *
+ * @param <InputT> the type of the input to this PTransform
+ * @param <OutputT> the type of the output to this PTransform
+ */
+public class YamlTransform<InputT extends PInput, OutputT extends POutput>
+    extends PTransform<InputT, OutputT> {
+
+  /** The YAML definition of this transform. */
+  private final String yamlDefinition;
+  /**
+   * If non-null, the set of input tags that are expected to be passed to this 
transform.
+   *
+   * <p>If null, a {@literal PCollection<Row>} or PBegin is expected.
+   */
+  private final @Nullable Set<String> inputTags;
+
+  /**
+   * If non-null, the set of output tags that are expected to be produced by 
this transform.
+   *
+   * <p>If null, exactly one output is expected and will be returned as a 
{@literal
+   * PCollection<Row>}.
+   */
+  private final @Nullable Set<String> outputTags;
+
+  private YamlTransform(
+      String yamlDefinition,
+      @Nullable Iterable<String> inputTags,
+      @Nullable Iterable<String> outputTags) {
+    this.yamlDefinition = yamlDefinition;
+    this.inputTags = inputTags == null ? null : ImmutableSet.copyOf(inputTags);
+    this.outputTags = outputTags == null ? null : 
ImmutableSet.copyOf(outputTags);
+  }
+
+  /**
+   * Creates a new YamlTransform mapping a single input {@literal 
PCollection<Row>} to a single
+   * {@literal PCollection<Row>} output.
+   *
+   * <p>Use {@link #withMultipleInputs} or {@link #withMultipleOutputs} to 
indicate that this
+   * transform has multiple inputs and/or outputs.
+   *
+   * @param yamlDefinition a YAML string defining this transform.
+   * @return a PTransform that applies this YAML to its inputs.
+   */
+  public static YamlTransform<PCollection<Row>, PCollection<Row>> of(String 
yamlDefinition) {
+    return new YamlTransform<PCollection<Row>, 
PCollection<Row>>(yamlDefinition, null, null);
+  }
+
+  /**
+   * Creates a new YamlTransform PBegin a single {@literal PCollection<Row>} 
output.
+   *
+   * @param yamlDefinition a YAML string defining this source.
+   * @return a PTransform that applies this YAML as a root transform.
+   */
+  public static YamlTransform<PBegin, PCollection<Row>> source(String 
yamlDefinition) {
+    return new YamlTransform<PBegin, PCollection<Row>>(yamlDefinition, null, 
null);
+  }
+
+  /**
+   * Creates a new YamlTransform mapping a single input {@literal 
PCollection<Row>} to a single
+   * {@literal PCollection<Row>} output.
+   *
+   * <p>Use {@link #withMultipleOutputs} to indicate that this sink has 
multiple (or no) or outputs.
+   *
+   * @param yamlDefinition a YAML string defining this sink.
+   * @return a PTransform that applies this YAML to its inputs.
+   */
+  public static YamlTransform<PCollection<Row>, PCollection<Row>> sink(String 
yamlDefinition) {
+    return of(yamlDefinition);
+  }
+
+  /**
+   * Indicates that this YamlTransform expects multiple, named inputs.
+   *
+   * @param inputTags the set of expected input tags to this transform
+   * @return a PTransform like this but with a {@link PCollectionRowTuple} 
input type.
+   */
+  public YamlTransform<PCollectionRowTuple, OutputT> 
withMultipleInputs(String... inputTags) {
+    return new YamlTransform<PCollectionRowTuple, OutputT>(
+        yamlDefinition, ImmutableSet.copyOf(inputTags), outputTags);
+  }
+
+  /**
+   * Indicates that this YamlTransform expects multiple, named outputs.
+   *
+   * @param outputTags the set of expected output tags to this transform
+   * @return a PTransform like this but with a {@link PCollectionRowTuple} 
output type.
+   */
+  public YamlTransform<InputT, PCollectionRowTuple> 
withMultipleOutputs(String... outputTags) {
+    return new YamlTransform<InputT, PCollectionRowTuple>(
+        yamlDefinition, inputTags, ImmutableSet.copyOf(outputTags));
+  }
+
+  @Override
+  public OutputT expand(InputT input) {
+    if (inputTags != null) {
+      Set<String> actualInputTags =
+          input.expand().keySet().stream()
+              .map(TupleTag::getId)
+              .collect(Collectors.toCollection(HashSet::new));
+      if (!inputTags.equals(actualInputTags)) {
+        throw new IllegalArgumentException(
+            "Input has tags "
+                + Joiner.on(", ").join(actualInputTags)
+                + " but expected input tags "
+                + Joiner.on(", ").join(inputTags));
+      }
+    }
+
+    // There is no generic apply...
+    POutput output;
+    @SuppressWarnings("rawtypes")
+    PTransform externalTransform =
+        
PythonExternalTransform.from("apache_beam.yaml.yaml_transform.YamlTransform")
+            .withArgs(yamlDefinition)
+            .withExtraPackages(ImmutableList.of("jinja2", "pyyaml", 
"virtualenv-clone"));
+    if (input instanceof PBegin) {
+      output = ((PBegin) input).apply(externalTransform);
+    } else if (input instanceof PCollection) {
+      output = ((PCollection<?>) input).apply(externalTransform);
+    } else if (input instanceof PCollection) {
+      output = ((PCollection<?>) input).apply(externalTransform);
+    } else if (input instanceof PCollectionRowTuple) {
+      output = ((PCollectionRowTuple) input).apply(externalTransform);
+    } else {
+      throw new IllegalArgumentException("Unrecognized input type: " + input);
+    }
+
+    if (outputTags == null) {
+      if (!(output instanceof PCollection)) {
+        throw new IllegalArgumentException(
+            "Expected a single PCollection output, but got "
+                + output
+                + ". Perhaps withMultipleOutputs() needs to be specified?");
+      }
+      return (OutputT) output;
+    } else {
+      if (output instanceof PCollection) {
+        // ExternalPythonTransform always returns single outputs as 
PCollections.
+        if (outputTags.size() != 1) {
+          throw new IllegalArgumentException(
+              "Expected " + outputTags.size() + " outputs, but got exactly 
one.");
+        }
+        return (OutputT)
+            PCollectionRowTuple.of(outputTags.iterator().next(), 
(PCollection<Row>) output);
+      } else {
+        Map<TupleTag<?>, PValue> expandedOutputs = output.expand();
+        Set<String> actualOutputTags =
+            expandedOutputs.keySet().stream()
+                .map(TupleTag::getId)
+                .collect(Collectors.toCollection(HashSet::new));
+        if (!outputTags.equals(actualOutputTags)) {
+          throw new IllegalArgumentException(
+              "Output has tags "
+                  + Joiner.on(", ").join(actualOutputTags)
+                  + " but expected output tags "
+                  + Joiner.on(", ").join(outputTags));
+        }
+        PCollectionRowTuple result = 
PCollectionRowTuple.empty(input.getPipeline());
+        for (Map.Entry<TupleTag<?>, PValue> subOutput : 
expandedOutputs.entrySet()) {
+          result = result.and(subOutput.getKey().getId(), (PCollection<Row>) 
subOutput.getValue());
+        }
+        return (OutputT) result;
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/package-info.java
 
b/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/package-info.java
new file mode 100644
index 00000000000..d50f2f2a91c
--- /dev/null
+++ 
b/sdks/java/extensions/yaml/src/main/java/org/apache/beam/sdk/extensions/yaml/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+/** Extensions for invoking Beam YAML transforms from the Beam Java SDK. */
+package org.apache.beam.sdk.extensions.yaml;
diff --git 
a/sdks/java/extensions/yaml/src/test/java/org/apache/beam/sdk/extensions/yaml/YamlTransformTest.java
 
b/sdks/java/extensions/yaml/src/test/java/org/apache/beam/sdk/extensions/yaml/YamlTransformTest.java
new file mode 100644
index 00000000000..50e3dfda6a3
--- /dev/null
+++ 
b/sdks/java/extensions/yaml/src/test/java/org/apache/beam/sdk/extensions/yaml/YamlTransformTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.beam.sdk.extensions.yaml;
+
+import static org.junit.Assume.assumeTrue;
+
+import java.io.Serializable;
+import org.apache.beam.runners.portability.PortableRunner;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.options.PortablePipelineOptions;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class YamlTransformTest implements Serializable {
+
+  // See build.gradle for test task configuration.
+  private static final String PORTABLE_RUNNER_PROPERTY_NAME = 
"testRunner.jobEndpoint";
+
+  private static final Schema INPUT_SCHEMA =
+      Schema.of(
+          Schema.Field.of("name", Schema.FieldType.STRING),
+          Schema.Field.of("email", Schema.FieldType.STRING));
+  private static final Row ALICE =
+      
Row.withSchema(INPUT_SCHEMA).addValue("alice").addValue("[email protected]").build();
+  private static final Row BOB =
+      
Row.withSchema(INPUT_SCHEMA).addValue("bob").addValue("[email protected]").build();
+
+  @Rule public TestPipeline pipeline = TestPipeline.fromOptions(options());
+
+  @Test
+  public void simpleYamlTransform() {
+    PCollection<Row> output =
+        pipeline
+            .apply(Create.of(ALICE, BOB))
+            .setRowSchema(INPUT_SCHEMA)
+            .apply(
+                YamlTransform.of(
+                    "{type: Filter, config: {language: python, keep: 
'len(name) < 4'}}"));
+    PAssert.that(output).containsInAnyOrder(BOB);
+
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void multipleOutputYamlTransform() {
+    PCollectionRowTuple output =
+        pipeline
+            .apply(Create.of(ALICE, BOB))
+            .setRowSchema(INPUT_SCHEMA)
+            .apply(
+                // TODO(https://github.com/apache/beam/pull/34595): Use 
Partition transform.
+                // This needs to wait until the above change is in a released 
version of Beam.
+                // YamlTransform.of(
+                //         "{type: Partition, "
+                //             + " config: {language: python, by: 'name[0]', 
outputs: [a, b]}}")
+                //     .withMultipleOutputs("a", "b"));
+                YamlTransform.of(
+                        "type: composite \n"
+                            + "input: input \n"
+                            + "transforms: [ \n"
+                            + "   {type: Filter, name: A, input: input, "
+                            + "    config: {language: python, keep: 'name[0] 
== \"a\"'}}, \n"
+                            + "   {type: Filter, name: B, input: input, "
+                            + "    config: {language: python, keep: 'name[0] 
== \"b\"'}} \n"
+                            + "] \n"
+                            + "output: {a: A, b: B} \n"
+                            + "")
+                    .withMultipleOutputs("a", "b"));
+    PAssert.that(output.get("a")).containsInAnyOrder(ALICE);
+    PAssert.that(output.get("b")).containsInAnyOrder(BOB);
+
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void yamlSource() {
+    PCollection<Row> output =
+        pipeline.apply(
+            YamlTransform.source(
+                "{type: Create, "
+                    + " config: {elements: [{name: alice, email: 
[email protected]}]}}"));
+    PAssert.that(output).containsInAnyOrder(ALICE);
+
+    // TODO: Run this on a multi-language supporting runner.
+    pipeline.run().waitUntilFinish();
+  }
+
+  private static PipelineOptions options() {
+    PortablePipelineOptions opts =
+        PipelineOptionsFactory.create().as(PortablePipelineOptions.class);
+
+    opts.setRunner(PortableRunner.class);
+    // TODO(https://github.com/apache/beam/issues/34594):Use LOOPBACK here.
+    opts.setDefaultEnvironmentType("DOCKER");
+    opts.setJobEndpoint(getLocalRunnerAddress());
+
+    return opts;
+  }
+
+  /**
+   * Drives ignoring of tests via checking {@link org.junit.Assume#assumeTrue} 
that the {@link
+   * System#getProperty} for {@link #PORTABLE_RUNNER_PROPERTY_NAME} is not 
null or empty.
+   */
+  private static String getLocalRunnerAddress() {
+    String address = System.getProperty(PORTABLE_RUNNER_PROPERTY_NAME);
+    assumeTrue(
+        "System property: "
+            + PORTABLE_RUNNER_PROPERTY_NAME
+            + " is not set; start a local runner and pass "
+            + "-D"
+            + PORTABLE_RUNNER_PROPERTY_NAME
+            + "=localhost:port",
+        !Strings.isNullOrEmpty(address));
+    return address;
+  }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4ca8d32d9e9..4e71a330856 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -205,6 +205,7 @@ include(":sdks:java:extensions:sql:expansion-service")
 include(":sdks:java:extensions:sql:udf")
 include(":sdks:java:extensions:sql:udf-test-provider")
 include(":sdks:java:extensions:timeseries")
+include(":sdks:java:extensions:yaml")
 include(":sdks:java:extensions:zetasketch")
 include(":sdks:java:harness")
 include(":sdks:java:harness:jmh")

Reply via email to