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

derrickaw 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 c2474960db7 [yaml] - expand jinja method (#38547)
c2474960db7 is described below

commit c2474960db705c7ac8a39a766be94893a1953667
Author: Derrick Williams <[email protected]>
AuthorDate: Mon Jun 22 17:46:00 2026 -0400

    [yaml] - expand jinja method (#38547)
    
    * expand jinja
    
    * minor tweaks
    
    * fix lint
    
    * fix gemini comments
    
    * add more documentation
---
 sdks/python/apache_beam/yaml/examples/README.md    |   4 +
 sdks/python/apache_beam/yaml/main.py               |   7 +-
 sdks/python/apache_beam/yaml/yaml_provider.py      |  11 +-
 sdks/python/apache_beam/yaml/yaml_transform.py     |  56 ++++++++--
 .../python/apache_beam/yaml/yaml_transform_test.py | 119 +++++++++++++++++++++
 .../www/site/content/en/documentation/sdks/yaml.md |  34 ++++++
 6 files changed, 222 insertions(+), 9 deletions(-)

diff --git a/sdks/python/apache_beam/yaml/examples/README.md 
b/sdks/python/apache_beam/yaml/examples/README.md
index 55fd19bd8c4..5282951c696 100644
--- a/sdks/python/apache_beam/yaml/examples/README.md
+++ b/sdks/python/apache_beam/yaml/examples/README.md
@@ -256,6 +256,10 @@ Jinja `% include` directive:
 - 
[wordCountInclude.yaml](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/include/wordCountInclude.yaml)
 - 
[Instructions](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/include/README.md)
 on how to run the pipeline.
 
+Jinja template inheritance (`{% extends %}`):
+- 
[wordCountInheritance.yaml](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/inheritance/wordCountInheritance.yaml)
+- 
[Instructions](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/inheritance/README.md)
 on how to run the pipeline.
+
 
 ### ML
 
diff --git a/sdks/python/apache_beam/yaml/main.py 
b/sdks/python/apache_beam/yaml/main.py
index dc928dec794..804798b82e0 100644
--- a/sdks/python/apache_beam/yaml/main.py
+++ b/sdks/python/apache_beam/yaml/main.py
@@ -235,8 +235,13 @@ def _build_pipeline_yaml_from_argv(argv):
   argv = _preparse_jinja_flags(argv)
   known_args, pipeline_args = _parse_arguments(argv)
   pipeline_template = _pipeline_spec_from_args(known_args)
+
+  search_paths = []
+  if known_args.yaml_pipeline_file:
+    search_paths.append(FileSystems.split(known_args.yaml_pipeline_file)[0])
+
   pipeline_yaml = yaml_transform.expand_jinja(
-      pipeline_template, known_args.jinja_variables or {})
+      pipeline_template, known_args.jinja_variables or {}, search_paths)
   return known_args, pipeline_args, pipeline_template, pipeline_yaml
 
 
diff --git a/sdks/python/apache_beam/yaml/yaml_provider.py 
b/sdks/python/apache_beam/yaml/yaml_provider.py
index ce5a6e32058..324ae0c2e73 100755
--- a/sdks/python/apache_beam/yaml/yaml_provider.py
+++ b/sdks/python/apache_beam/yaml/yaml_provider.py
@@ -469,10 +469,14 @@ class ExternalPythonProvider(ExternalProvider):
 
 @ExternalProvider.register_provider_type('yaml')
 class YamlProvider(Provider):
-  def __init__(self, transforms: Mapping[str, Mapping[str, Any]]):
+  def __init__(
+      self,
+      transforms: Mapping[str, Mapping[str, Any]],
+      provider_base_path: Optional[str] = None):
     if not isinstance(transforms, dict):
       raise ValueError('Transform mapping must be a dict.')
     self._transforms = transforms
+    self._provider_base_path = provider_base_path
 
   def available(self):
     return True
@@ -524,7 +528,10 @@ class YamlProvider(Provider):
     else:
       body_str = yaml.safe_dump(SafeLineLoader.strip_metadata(body))
     # Now re-parse resolved templatization.
-    body = yaml.load(expand_jinja(body_str, args), Loader=SafeLineLoader)
+    search_paths = [FileSystems.split(self._provider_base_path)[0]
+                    ] if self._provider_base_path else []
+    body = yaml.load(
+        expand_jinja(body_str, args, search_paths), Loader=SafeLineLoader)
     if (body.get('type') == 'chain' and 'input' not in body and
         spec.get('requires_inputs', True)):
       body['input'] = 'input'
diff --git a/sdks/python/apache_beam/yaml/yaml_transform.py 
b/sdks/python/apache_beam/yaml/yaml_transform.py
index 2b745babad0..a4bb9144f8e 100644
--- a/sdks/python/apache_beam/yaml/yaml_transform.py
+++ b/sdks/python/apache_beam/yaml/yaml_transform.py
@@ -1391,19 +1391,63 @@ def preprocess(spec, verbose=False, 
known_transforms=None):
   return spec
 
 
+def strip_leading_comments(source: str) -> str:
+  lines = source.splitlines(keepends=True)
+  stripped_lines = []
+  in_leading_comments = True
+  for line in lines:
+    stripped_line = line.lstrip()
+    if in_leading_comments:
+      if stripped_line.startswith('#') or not stripped_line:
+        continue
+      else:
+        in_leading_comments = False
+    stripped_lines.append(line)
+  return "".join(stripped_lines)
+
+
 class _BeamFileIOLoader(jinja2.BaseLoader):
+  def __init__(self, search_paths=()):
+    self.search_paths = list(search_paths)
+
   def get_source(self, environment, path):
-    with FileSystems.open(path) as fin:
-      source = fin.read().decode()
-    return source, path, lambda: True
+    candidates = [path]
+    if FileSystems.get_scheme(path) is None and not os.path.isabs(path):
+      for search_path in self.search_paths:
+        candidates.append(FileSystems.join(search_path, path))
+
+    for candidate in candidates:
+      try:
+        exists = FileSystems.exists(candidate)
+      except Exception:
+        exists = False
+
+      if exists:
+        with FileSystems.open(candidate) as fin:
+          source = fin.read().decode()
+        return strip_leading_comments(source), candidate, lambda: True
+
+    raise jinja2.TemplateNotFound(path)
 
 
 def expand_jinja(
-    jinja_template: str, jinja_variables: Mapping[str, Any]) -> str:
+    jinja_template: str,
+    jinja_variables: Mapping[str, Any],
+    search_paths: Iterable[str] = ()) -> str:
+  beam_root_dir = os.path.dirname(
+      os.path.dirname(os.path.abspath(beam.__file__)))
+
+  all_search_paths = list(search_paths)
+  if beam_root_dir not in all_search_paths:
+    all_search_paths.append(beam_root_dir)
+  if '.' not in all_search_paths:
+    all_search_paths.append('.')
+
   return (  # keep formatting
       jinja2.Environment(
-          undefined=jinja2.StrictUndefined, loader=_BeamFileIOLoader())
-      .from_string(jinja_template)
+          undefined=jinja2.StrictUndefined,
+          loader=_BeamFileIOLoader(all_search_paths))
+      .from_string(strip_leading_comments(jinja_template))
       .render(datetime=datetime, **jinja_variables))
 
 
diff --git a/sdks/python/apache_beam/yaml/yaml_transform_test.py 
b/sdks/python/apache_beam/yaml/yaml_transform_test.py
index 192af63a987..4cfe625fc9c 100644
--- a/sdks/python/apache_beam/yaml/yaml_transform_test.py
+++ b/sdks/python/apache_beam/yaml/yaml_transform_test.py
@@ -23,12 +23,16 @@ import shutil
 import tempfile
 import unittest
 
+import yaml
+
 import apache_beam as beam
 from apache_beam.testing.util import assert_that
 from apache_beam.testing.util import equal_to
 from apache_beam.utils import python_callable
 from apache_beam.yaml import yaml_provider
+from apache_beam.yaml.yaml_transform import SafeLineLoader
 from apache_beam.yaml.yaml_transform import YamlTransform
+from apache_beam.yaml.yaml_transform import expand_jinja
 
 try:
   import jsonschema
@@ -1467,6 +1471,65 @@ class TestExternalYamlProvider(unittest.TestCase):
             ''',
             providers=merged_providers)
 
+  def test_provider_with_jinja_imports(self):
+    # Create a macro file in the same temp directory as the provider
+    macro_path = os.path.join(self.temp_dir, 'my_macros.yaml')
+    with open(macro_path, 'w') as f:
+      f.write(
+          """
+{%- macro power_expr(var, n) -%}
+{{ var }} ** {{ n }}
+{%- endmacro -%}
+""")
+
+    # Create a provider that imports and uses the macro
+    templated_provider_path = os.path.join(
+        self.temp_dir, 'templated_provider.yaml')
+    with open(templated_provider_path, 'w') as f:
+      f.write(
+          """
+- type: yaml
+  transforms:
+    CustomPower:
+      config_schema:
+        properties:
+          n: {type: integer}
+      body: |
+        type: MapToFields
+        config:
+          language: python
+          append: true
+          fields:
+            power: "{% import 'my_macros.yaml' as m %}{{ 
m.power_expr('element', n) }}"
+""")
+
+    loaded_providers = yaml_provider.load_providers(templated_provider_path)
+    test_providers = yaml_provider.InlineProvider(TEST_PROVIDERS)
+    merged_providers = yaml_provider.merge_providers(
+        loaded_providers, [test_providers])
+
+    with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
+        pickle_library='cloudpickle')) as p:
+      results = p | YamlTransform(
+          '''
+          type: composite
+          transforms:
+            - type: Create
+              config:
+                elements: [2, 3]
+            - type: CustomPower
+              input: Create
+              config:
+                n: 3
+          output: CustomPower
+          ''',
+          providers=merged_providers)
+
+      assert_that(
+          results,
+          equal_to(
+              [beam.Row(element=2, power=8), beam.Row(element=3, power=27)]))
+
 
 @beam.transforms.ptransform.annotate_yaml
 class LinearTransform(beam.PTransform):
@@ -1481,6 +1544,62 @@ class LinearTransform(beam.PTransform):
     return pcoll | beam.Map(lambda x: a * x.element + b)
 
 
+class TestYamlExpandJinja(unittest.TestCase):
+  def setUp(self):
+    self.temp_dir = tempfile.mkdtemp()
+    # Create a macro file with leading comments (license header)
+    self.macro_path = os.path.join(self.temp_dir, 'my_macros.yaml')
+    with open(self.macro_path, 'w') as f:
+      f.write(
+          """# coding=utf-8
+# Licensed to the Apache Software Foundation...
+# Some leading comment line
+
+{%- macro add_n(val, n) -%}
+{{ val }} + {{ n }}
+{%- endmacro -%}
+""")
+
+    # Create a pipeline template that includes/imports the macro
+    self.pipeline_path = os.path.join(self.temp_dir, 'my_pipeline.yaml')
+    with open(self.pipeline_path, 'w') as f:
+      f.write(
+          """# coding=utf-8
+# Licensed to the Apache Software Foundation...
+
+{% import 'my_macros.yaml' as macros %}
+type: composite
+transforms:
+  - type: Create
+    config:
+      elements: [1, 2, 3]
+  - type: MapToFields
+    config:
+      language: python
+      fields:
+        result: {{ macros.add_n('element', 10) }}
+""")
+
+  def tearDown(self):
+    shutil.rmtree(self.temp_dir)
+
+  def test_expand_jinja_with_leading_comments_and_imports(self):
+    # Read the pipeline template
+    with open(self.pipeline_path, 'r') as f:
+      template_content = f.read()
+
+    # Expand the jinja using our temp_dir as a search path
+    expanded = expand_jinja(template_content, {}, [self.temp_dir])
+
+    # Parse the expanded YAML
+    parsed = yaml.load(expanded, Loader=SafeLineLoader)
+
+    # Verify the comment-stripping and import resolution was successful
+    self.assertEqual(parsed['type'], 'composite')
+    self.assertEqual(
+        parsed['transforms'][1]['config']['fields']['result'], 'element + 10')
+
+
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
   unittest.main()
diff --git a/website/www/site/content/en/documentation/sdks/yaml.md 
b/website/www/site/content/en/documentation/sdks/yaml.md
index 589679d3e50..a1fda30ddb6 100644
--- a/website/www/site/content/en/documentation/sdks/yaml.md
+++ b/website/www/site/content/en/documentation/sdks/yaml.md
@@ -812,6 +812,40 @@ pipeline:
 This pipeline can be run with the same command as in the `% include` example
 above.
 
+You can also use template inheritance (`{% extends %}` and `{% block %}`) to 
define a base pipeline layout and allow child pipelines to override specific 
blocks of transforms:
+
+<PATH_TO_YOUR_REPO>/base_pipeline.yaml
+```yaml
+pipeline:
+  type: chain
+  transforms:
+    - type: ReadFromText
+      config:
+        path: {{ input_path }}
+
+# Injection point for child templates
+{% block custom_steps %}
+{% endblock %}
+
+    - type: WriteToText
+      config:
+        path: {{ output_path }}
+```
+
+<PATH_TO_YOUR_REPO>/child_pipeline.yaml
+```yaml
+{% extends '<PATH_TO_YOUR_REPO>/base_pipeline.yaml' %}
+
+{% block custom_steps %}
+    - type: Filter
+      config:
+        language: python
+        keep: 'row.count > 10'
+{% endblock %}
+```
+
+This pipeline can be run using the exact same command structure as above.
+
 There are many more ways to import and even use template inheritance using
 Jinja as seen 
[here](https://jinja.palletsprojects.com/en/stable/templates/#import)
 and [here](https://jinja.palletsprojects.com/en/stable/templates/#inheritance).

Reply via email to