Skip to content

Commit c247496

Browse files
authored
[yaml] - expand jinja method (#38547)
* expand jinja * minor tweaks * fix lint * fix gemini comments * add more documentation
1 parent 9868954 commit c247496

6 files changed

Lines changed: 222 additions & 9 deletions

File tree

sdks/python/apache_beam/yaml/examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ Jinja `% include` directive:
256256
- [wordCountInclude.yaml](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/include/wordCountInclude.yaml)
257257
- [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.
258258

259+
Jinja template inheritance (`{% extends %}`):
260+
- [wordCountInheritance.yaml](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/yaml/examples/transforms/jinja/inheritance/wordCountInheritance.yaml)
261+
- [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.
262+
259263

260264
### ML
261265

sdks/python/apache_beam/yaml/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,13 @@ def _build_pipeline_yaml_from_argv(argv):
235235
argv = _preparse_jinja_flags(argv)
236236
known_args, pipeline_args = _parse_arguments(argv)
237237
pipeline_template = _pipeline_spec_from_args(known_args)
238+
239+
search_paths = []
240+
if known_args.yaml_pipeline_file:
241+
search_paths.append(FileSystems.split(known_args.yaml_pipeline_file)[0])
242+
238243
pipeline_yaml = yaml_transform.expand_jinja(
239-
pipeline_template, known_args.jinja_variables or {})
244+
pipeline_template, known_args.jinja_variables or {}, search_paths)
240245
return known_args, pipeline_args, pipeline_template, pipeline_yaml
241246

242247

sdks/python/apache_beam/yaml/yaml_provider.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,14 @@ def _with_extra_dependencies(self, dependencies: Iterable[str]):
469469

470470
@ExternalProvider.register_provider_type('yaml')
471471
class YamlProvider(Provider):
472-
def __init__(self, transforms: Mapping[str, Mapping[str, Any]]):
472+
def __init__(
473+
self,
474+
transforms: Mapping[str, Mapping[str, Any]],
475+
provider_base_path: Optional[str] = None):
473476
if not isinstance(transforms, dict):
474477
raise ValueError('Transform mapping must be a dict.')
475478
self._transforms = transforms
479+
self._provider_base_path = provider_base_path
476480

477481
def available(self):
478482
return True
@@ -524,7 +528,10 @@ def create_transform(
524528
else:
525529
body_str = yaml.safe_dump(SafeLineLoader.strip_metadata(body))
526530
# Now re-parse resolved templatization.
527-
body = yaml.load(expand_jinja(body_str, args), Loader=SafeLineLoader)
531+
search_paths = [FileSystems.split(self._provider_base_path)[0]
532+
] if self._provider_base_path else []
533+
body = yaml.load(
534+
expand_jinja(body_str, args, search_paths), Loader=SafeLineLoader)
528535
if (body.get('type') == 'chain' and 'input' not in body and
529536
spec.get('requires_inputs', True)):
530537
body['input'] = 'input'

sdks/python/apache_beam/yaml/yaml_transform.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,19 +1391,63 @@ def validate_transform_references(spec):
13911391
return spec
13921392

13931393

1394+
def strip_leading_comments(source: str) -> str:
1395+
lines = source.splitlines(keepends=True)
1396+
stripped_lines = []
1397+
in_leading_comments = True
1398+
for line in lines:
1399+
stripped_line = line.lstrip()
1400+
if in_leading_comments:
1401+
if stripped_line.startswith('#') or not stripped_line:
1402+
continue
1403+
else:
1404+
in_leading_comments = False
1405+
stripped_lines.append(line)
1406+
return "".join(stripped_lines)
1407+
1408+
13941409
class _BeamFileIOLoader(jinja2.BaseLoader):
1410+
def __init__(self, search_paths=()):
1411+
self.search_paths = list(search_paths)
1412+
13951413
def get_source(self, environment, path):
1396-
with FileSystems.open(path) as fin:
1397-
source = fin.read().decode()
1398-
return source, path, lambda: True
1414+
candidates = [path]
1415+
if FileSystems.get_scheme(path) is None and not os.path.isabs(path):
1416+
for search_path in self.search_paths:
1417+
candidates.append(FileSystems.join(search_path, path))
1418+
1419+
for candidate in candidates:
1420+
try:
1421+
exists = FileSystems.exists(candidate)
1422+
except Exception:
1423+
exists = False
1424+
1425+
if exists:
1426+
with FileSystems.open(candidate) as fin:
1427+
source = fin.read().decode()
1428+
return strip_leading_comments(source), candidate, lambda: True
1429+
1430+
raise jinja2.TemplateNotFound(path)
13991431

14001432

14011433
def expand_jinja(
1402-
jinja_template: str, jinja_variables: Mapping[str, Any]) -> str:
1434+
jinja_template: str,
1435+
jinja_variables: Mapping[str, Any],
1436+
search_paths: Iterable[str] = ()) -> str:
1437+
beam_root_dir = os.path.dirname(
1438+
os.path.dirname(os.path.abspath(beam.__file__)))
1439+
1440+
all_search_paths = list(search_paths)
1441+
if beam_root_dir not in all_search_paths:
1442+
all_search_paths.append(beam_root_dir)
1443+
if '.' not in all_search_paths:
1444+
all_search_paths.append('.')
1445+
14031446
return ( # keep formatting
14041447
jinja2.Environment(
1405-
undefined=jinja2.StrictUndefined, loader=_BeamFileIOLoader())
1406-
.from_string(jinja_template)
1448+
undefined=jinja2.StrictUndefined,
1449+
loader=_BeamFileIOLoader(all_search_paths))
1450+
.from_string(strip_leading_comments(jinja_template))
14071451
.render(datetime=datetime, **jinja_variables))
14081452

14091453

sdks/python/apache_beam/yaml/yaml_transform_test.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
import tempfile
2424
import unittest
2525

26+
import yaml
27+
2628
import apache_beam as beam
2729
from apache_beam.testing.util import assert_that
2830
from apache_beam.testing.util import equal_to
2931
from apache_beam.utils import python_callable
3032
from apache_beam.yaml import yaml_provider
33+
from apache_beam.yaml.yaml_transform import SafeLineLoader
3134
from apache_beam.yaml.yaml_transform import YamlTransform
35+
from apache_beam.yaml.yaml_transform import expand_jinja
3236

3337
try:
3438
import jsonschema
@@ -1467,6 +1471,65 @@ def test_must_consume_error_output(self):
14671471
''',
14681472
providers=merged_providers)
14691473

1474+
def test_provider_with_jinja_imports(self):
1475+
# Create a macro file in the same temp directory as the provider
1476+
macro_path = os.path.join(self.temp_dir, 'my_macros.yaml')
1477+
with open(macro_path, 'w') as f:
1478+
f.write(
1479+
"""
1480+
{%- macro power_expr(var, n) -%}
1481+
{{ var }} ** {{ n }}
1482+
{%- endmacro -%}
1483+
""")
1484+
1485+
# Create a provider that imports and uses the macro
1486+
templated_provider_path = os.path.join(
1487+
self.temp_dir, 'templated_provider.yaml')
1488+
with open(templated_provider_path, 'w') as f:
1489+
f.write(
1490+
"""
1491+
- type: yaml
1492+
transforms:
1493+
CustomPower:
1494+
config_schema:
1495+
properties:
1496+
n: {type: integer}
1497+
body: |
1498+
type: MapToFields
1499+
config:
1500+
language: python
1501+
append: true
1502+
fields:
1503+
power: "{% import 'my_macros.yaml' as m %}{{ m.power_expr('element', n) }}"
1504+
""")
1505+
1506+
loaded_providers = yaml_provider.load_providers(templated_provider_path)
1507+
test_providers = yaml_provider.InlineProvider(TEST_PROVIDERS)
1508+
merged_providers = yaml_provider.merge_providers(
1509+
loaded_providers, [test_providers])
1510+
1511+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
1512+
pickle_library='cloudpickle')) as p:
1513+
results = p | YamlTransform(
1514+
'''
1515+
type: composite
1516+
transforms:
1517+
- type: Create
1518+
config:
1519+
elements: [2, 3]
1520+
- type: CustomPower
1521+
input: Create
1522+
config:
1523+
n: 3
1524+
output: CustomPower
1525+
''',
1526+
providers=merged_providers)
1527+
1528+
assert_that(
1529+
results,
1530+
equal_to(
1531+
[beam.Row(element=2, power=8), beam.Row(element=3, power=27)]))
1532+
14701533

14711534
@beam.transforms.ptransform.annotate_yaml
14721535
class LinearTransform(beam.PTransform):
@@ -1481,6 +1544,62 @@ def expand(self, pcoll):
14811544
return pcoll | beam.Map(lambda x: a * x.element + b)
14821545

14831546

1547+
class TestYamlExpandJinja(unittest.TestCase):
1548+
def setUp(self):
1549+
self.temp_dir = tempfile.mkdtemp()
1550+
# Create a macro file with leading comments (license header)
1551+
self.macro_path = os.path.join(self.temp_dir, 'my_macros.yaml')
1552+
with open(self.macro_path, 'w') as f:
1553+
f.write(
1554+
"""# coding=utf-8
1555+
# Licensed to the Apache Software Foundation...
1556+
# Some leading comment line
1557+
1558+
{%- macro add_n(val, n) -%}
1559+
{{ val }} + {{ n }}
1560+
{%- endmacro -%}
1561+
""")
1562+
1563+
# Create a pipeline template that includes/imports the macro
1564+
self.pipeline_path = os.path.join(self.temp_dir, 'my_pipeline.yaml')
1565+
with open(self.pipeline_path, 'w') as f:
1566+
f.write(
1567+
"""# coding=utf-8
1568+
# Licensed to the Apache Software Foundation...
1569+
1570+
{% import 'my_macros.yaml' as macros %}
1571+
type: composite
1572+
transforms:
1573+
- type: Create
1574+
config:
1575+
elements: [1, 2, 3]
1576+
- type: MapToFields
1577+
config:
1578+
language: python
1579+
fields:
1580+
result: {{ macros.add_n('element', 10) }}
1581+
""")
1582+
1583+
def tearDown(self):
1584+
shutil.rmtree(self.temp_dir)
1585+
1586+
def test_expand_jinja_with_leading_comments_and_imports(self):
1587+
# Read the pipeline template
1588+
with open(self.pipeline_path, 'r') as f:
1589+
template_content = f.read()
1590+
1591+
# Expand the jinja using our temp_dir as a search path
1592+
expanded = expand_jinja(template_content, {}, [self.temp_dir])
1593+
1594+
# Parse the expanded YAML
1595+
parsed = yaml.load(expanded, Loader=SafeLineLoader)
1596+
1597+
# Verify the comment-stripping and import resolution was successful
1598+
self.assertEqual(parsed['type'], 'composite')
1599+
self.assertEqual(
1600+
parsed['transforms'][1]['config']['fields']['result'], 'element + 10')
1601+
1602+
14841603
if __name__ == '__main__':
14851604
logging.getLogger().setLevel(logging.INFO)
14861605
unittest.main()

website/www/site/content/en/documentation/sdks/yaml.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,40 @@ pipeline:
812812
This pipeline can be run with the same command as in the `% include` example
813813
above.
814814

815+
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:
816+
817+
<PATH_TO_YOUR_REPO>/base_pipeline.yaml
818+
```yaml
819+
pipeline:
820+
type: chain
821+
transforms:
822+
- type: ReadFromText
823+
config:
824+
path: {{ input_path }}
825+
826+
# Injection point for child templates
827+
{% block custom_steps %}
828+
{% endblock %}
829+
830+
- type: WriteToText
831+
config:
832+
path: {{ output_path }}
833+
```
834+
835+
<PATH_TO_YOUR_REPO>/child_pipeline.yaml
836+
```yaml
837+
{% extends '<PATH_TO_YOUR_REPO>/base_pipeline.yaml' %}
838+
839+
{% block custom_steps %}
840+
- type: Filter
841+
config:
842+
language: python
843+
keep: 'row.count > 10'
844+
{% endblock %}
845+
```
846+
847+
This pipeline can be run using the exact same command structure as above.
848+
815849
There are many more ways to import and even use template inheritance using
816850
Jinja as seen [here](https://jinja.palletsprojects.com/en/stable/templates/#import)
817851
and [here](https://jinja.palletsprojects.com/en/stable/templates/#inheritance).

0 commit comments

Comments
 (0)