Skip to content

Commit 4049f00

Browse files
authored
[yaml] add support for matchall (#38512)
* add support for matchall * fix lint * fix more lint * fix gemini comments * move matchAll to standard providers
1 parent 1b261f0 commit 4049f00

5 files changed

Lines changed: 273 additions & 0 deletions

File tree

sdks/python/apache_beam/yaml/standard_io.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
'ReadFromTFRecord': 'apache_beam.yaml.yaml_io.read_from_tfrecord'
117117
'WriteToTFRecord': 'apache_beam.yaml.yaml_io.write_to_tfrecord'
118118

119+
119120
# General File Formats
120121
# Declared as a renaming transform to avoid exposing all
121122
# (implementation-specific) pandas arguments and aligning with possible Java

sdks/python/apache_beam/yaml/standard_providers.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
transforms:
5858
MLTransform: 'apache_beam.yaml.yaml_ml.ml_transform'
5959
RunInference: 'apache_beam.yaml.yaml_ml.run_inference'
60+
MatchAll: 'apache_beam.yaml.yaml_io.match_all'
6061

6162
- type: renaming
6263
transforms:
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
fixtures:
19+
- name: TEMP_DIR
20+
type: "tempfile.TemporaryDirectory"
21+
22+
pipelines:
23+
# 1. Write first matching file to temp directory
24+
- pipeline:
25+
type: chain
26+
transforms:
27+
- type: Create
28+
config:
29+
elements:
30+
- {value: "a"}
31+
- type: WriteToText
32+
config:
33+
path: "{TEMP_DIR}/match1.txt"
34+
35+
# 2. Write second matching file to temp directory
36+
- pipeline:
37+
type: chain
38+
transforms:
39+
- type: Create
40+
config:
41+
elements:
42+
- {value: "b"}
43+
- type: WriteToText
44+
config:
45+
path: "{TEMP_DIR}/match2.txt"
46+
47+
# 3. Write an ignore file to temp directory (should not be matched)
48+
- pipeline:
49+
type: chain
50+
transforms:
51+
- type: Create
52+
config:
53+
elements:
54+
- {value: "c"}
55+
- type: WriteToText
56+
config:
57+
path: "{TEMP_DIR}/ignore.txt"
58+
59+
# 4. Match files using MatchAll and verify only match1 and match2 are found
60+
- pipeline:
61+
type: chain
62+
transforms:
63+
- type: Create
64+
config:
65+
elements:
66+
- {pattern: "{TEMP_DIR}/match*.txt*"}
67+
- type: MatchAll
68+
config:
69+
file_pattern: pattern
70+
- type: MapToFields
71+
config:
72+
language: python
73+
fields:
74+
filename: "path.split('/')[-1].split('-')[0]"
75+
- type: AssertEqual
76+
config:
77+
elements:
78+
- {filename: "match1.txt"}
79+
- {filename: "match2.txt"}
80+
81+
# 5. Match non-existent files with ALLOW empty_match_treatment (should succeed but return empty PCollection)
82+
- pipeline:
83+
type: chain
84+
transforms:
85+
- type: Create
86+
config:
87+
elements:
88+
- {pattern: "{TEMP_DIR}/does_not_exist*.txt*"}
89+
- type: MatchAll
90+
config:
91+
file_pattern: pattern
92+
empty_match_treatment: ALLOW
93+
- type: AssertEqual
94+
config:
95+
elements: []
96+

sdks/python/apache_beam/yaml/yaml_io.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,3 +723,60 @@ def write_to_tfrecord(
723723
num_shards=num_shards,
724724
shard_name_template=shard_name_template,
725725
compression_type=getattr(CompressionTypes, compression_type))
726+
727+
728+
@beam.ptransform_fn
729+
def match_all(
730+
pcoll,
731+
*,
732+
file_pattern: Optional[str] = None,
733+
empty_match_treatment: str = 'ALLOW',
734+
):
735+
"""Matches file patterns from the input PCollection.
736+
737+
This transform returns a PCollection of matching files, each represented as a
738+
Row with path, size_in_bytes, and last_updated_in_seconds fields.
739+
740+
Args:
741+
file_pattern (str): The name of the field in the input PCollection that contains
742+
the file pattern string. If not specified and the input PCollection has
743+
exactly one field, that field will be used.
744+
empty_match_treatment (str): How to treat empty matches. Possible values are
745+
'ALLOW', 'DISALLOW', and 'ALLOW_IF_WILDCARD'. Defaults to 'ALLOW'.
746+
"""
747+
from apache_beam.typehints import schemas
748+
749+
try:
750+
field_names = [
751+
name for name, _ in schemas.named_fields_from_element_type(
752+
pcoll.element_type)
753+
]
754+
except Exception:
755+
field_names = None
756+
757+
if field_names:
758+
if file_pattern is not None:
759+
if file_pattern not in field_names:
760+
raise ValueError(
761+
f"Field '{file_pattern}' not found in input schema fields: {field_names}"
762+
)
763+
pattern_field = file_pattern
764+
elif len(field_names) == 1:
765+
pattern_field = field_names[0]
766+
else:
767+
raise ValueError(
768+
f"Input schema has multiple fields {field_names}. "
769+
f"Please specify the 'file_pattern' parameter to select which field "
770+
f"contains the file pattern.")
771+
patterns = pcoll | beam.Map(lambda x: str(getattr(x, pattern_field)))
772+
else:
773+
patterns = pcoll
774+
775+
matched = patterns | beam.io.fileio.MatchAll(
776+
empty_match_treatment=empty_match_treatment)
777+
778+
return matched | beam.Map(
779+
lambda x: beam.Row(
780+
path=str(x.path), size_in_bytes=int(x.size_in_bytes),
781+
last_updated_in_seconds=float(x.last_updated_in_seconds)
782+
if x.last_updated_in_seconds is not None else None))

sdks/python/apache_beam/yaml/yaml_io_test.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import io
1919
import json
2020
import logging
21+
import os
22+
import tempfile
2123
import unittest
2224

2325
import fastavro
@@ -543,6 +545,122 @@ def test_read_proto(self):
543545
assert_that(result, equal_to(data))
544546

545547

548+
class YamlMatchAllTest(unittest.TestCase):
549+
def test_match_all_simple(self):
550+
with tempfile.TemporaryDirectory() as temp_dir:
551+
file1 = os.path.join(temp_dir, 'file1.txt')
552+
file2 = os.path.join(temp_dir, 'file2.txt')
553+
for f in [file1, file2]:
554+
with open(f, 'w') as fout:
555+
fout.write('data')
556+
557+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
558+
pickle_library='cloudpickle')) as p:
559+
result = (
560+
p
561+
| beam.Create(
562+
[beam.Row(pattern=os.path.join(temp_dir, 'file*.txt'))])
563+
| YamlTransform(
564+
'''
565+
type: MatchAll
566+
config:
567+
file_pattern: pattern
568+
'''))
569+
paths = result | beam.Map(lambda row: row.path)
570+
assert_that(paths, equal_to([file1, file2]))
571+
572+
def test_match_all_single_field_default(self):
573+
with tempfile.TemporaryDirectory() as temp_dir:
574+
file1 = os.path.join(temp_dir, 'file1.txt')
575+
with open(file1, 'w') as fout:
576+
fout.write('data')
577+
578+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
579+
pickle_library='cloudpickle')) as p:
580+
result = (
581+
p
582+
| beam.Create([beam.Row(my_sole_pattern=file1)])
583+
| YamlTransform(
584+
'''
585+
type: MatchAll
586+
'''))
587+
paths = result | beam.Map(lambda row: row.path)
588+
assert_that(paths, equal_to([file1]))
589+
590+
def test_match_all_multiple_fields_error(self):
591+
with self.assertRaises(Exception):
592+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
593+
pickle_library='cloudpickle')) as p:
594+
_ = (
595+
p
596+
| beam.Create([beam.Row(pattern='foo', other_field='bar')])
597+
| YamlTransform(
598+
'''
599+
type: MatchAll
600+
'''))
601+
602+
def test_match_all_empty_match_disallow_error(self):
603+
with self.assertRaises(Exception):
604+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
605+
pickle_library='cloudpickle')) as p:
606+
_ = (
607+
p
608+
| beam.Create([beam.Row(pattern='does_not_exist*.txt')])
609+
| YamlTransform(
610+
'''
611+
type: MatchAll
612+
config:
613+
empty_match_treatment: DISALLOW
614+
'''))
615+
616+
def test_match_all_invalid_field_error(self):
617+
with self.assertRaisesRegex(
618+
ValueError, "Field 'invalid_field' not found in input schema fields"):
619+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
620+
pickle_library='cloudpickle')) as p:
621+
_ = (
622+
p
623+
| beam.Create([beam.Row(pattern='foo')])
624+
| YamlTransform(
625+
'''
626+
type: MatchAll
627+
config:
628+
file_pattern: invalid_field
629+
'''))
630+
631+
def test_match_all_none_timestamp(self):
632+
from apache_beam.io.filesystem import FileMetadata
633+
634+
class MockMatchAll(beam.PTransform):
635+
def expand(self, pcoll):
636+
return pcoll.pipeline | beam.Create([
637+
FileMetadata(
638+
path='file.txt',
639+
size_in_bytes=100,
640+
last_updated_in_seconds=None)
641+
])
642+
643+
with mock.patch('apache_beam.io.fileio.MatchAll',
644+
return_value=MockMatchAll()):
645+
with beam.Pipeline(options=beam.options.pipeline_options.PipelineOptions(
646+
pickle_library='cloudpickle')) as p:
647+
result = (
648+
p
649+
| beam.Create([beam.Row(pattern='file.txt')])
650+
| YamlTransform(
651+
'''
652+
type: MatchAll
653+
'''))
654+
assert_that(
655+
result,
656+
equal_to([
657+
beam.Row(
658+
path='file.txt',
659+
size_in_bytes=100,
660+
last_updated_in_seconds=None)
661+
]))
662+
663+
546664
if __name__ == '__main__':
547665
logging.getLogger().setLevel(logging.INFO)
548666
unittest.main()

0 commit comments

Comments
 (0)