Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-s3-49636.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "s3",
"description": "Compile ``--include``/``--exclude`` filter patterns once per transfer instead of re-normalizing and re-matching them for every file, reducing client-side filter evaluation time by roughly 40%."
}
54 changes: 44 additions & 10 deletions awscli/customizations/s3/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import fnmatch
import logging
import os
import re

from awscli.customizations.s3.utils import split_s3_bucket_key

Expand Down Expand Up @@ -94,6 +95,7 @@ def __init__(self, patterns, rootdir, dst_rootdir):
self._original_patterns = patterns
self.patterns = self._full_path_patterns(patterns, rootdir)
self.dst_patterns = self._full_path_patterns(patterns, dst_rootdir)
self._compiled_cache = {}

def _full_path_patterns(self, original_patterns, rootdir):
# We need to transform the patterns into patterns that have
Expand All @@ -119,14 +121,24 @@ def call(self, file_infos):
before it.
"""
for file_info in file_infos:
patterns, dst_patterns = self._compiled_patterns(
file_info.src_type
)
file_path = file_info.src
# ``fnmatch.fnmatch`` normcases both of its arguments on every
# call. The pattern side is already normcased when it is
# compiled, so only the path has to be normcased here, and only
# once for all of the patterns.
norm_file_path = os.path.normcase(file_path)
file_status = (file_info, True)
for pattern, dst_pattern in zip(self.patterns, self.dst_patterns):
current_file_status = self._match_pattern(pattern, file_info)
for pattern, dst_pattern in zip(patterns, dst_patterns):
current_file_status = self._match_pattern(
pattern, file_info, norm_file_path
)
if current_file_status is not None:
file_status = current_file_status
dst_current_file_status = self._match_pattern(
dst_pattern, file_info
dst_pattern, file_info, norm_file_path
)
if dst_current_file_status is not None:
file_status = dst_current_file_status
Expand All @@ -138,15 +150,37 @@ def call(self, file_infos):
if file_status[1]:
yield file_info

def _match_pattern(self, pattern, file_info):
def _compiled_patterns(self, src_type):
# The patterns are fixed for the duration of a transfer, so the
# separator normalization and the fnmatch -> regex translation are
# done once per source type rather than once per file.
compiled = self._compiled_cache.get(src_type)
if compiled is None:
compiled = (
self._compile_patterns(self.patterns, src_type),
self._compile_patterns(self.dst_patterns, src_type),
)
self._compiled_cache[src_type] = compiled
return compiled

def _compile_patterns(self, patterns, src_type):
compiled = []
for pattern_type, pattern in patterns:
if src_type == 'local':
path_pattern = pattern.replace('/', os.sep)
else:
path_pattern = pattern.replace(os.sep, '/')
regex = re.compile(
fnmatch.translate(os.path.normcase(path_pattern))
)
compiled.append((pattern_type, path_pattern, regex))
return compiled

def _match_pattern(self, pattern, file_info, norm_file_path):
file_status = None
file_path = file_info.src
pattern_type = pattern[0]
if file_info.src_type == 'local':
path_pattern = pattern[1].replace('/', os.sep)
else:
path_pattern = pattern[1].replace(os.sep, '/')
is_match = fnmatch.fnmatch(file_path, path_pattern)
pattern_type, path_pattern, regex = pattern
is_match = regex.match(norm_file_path) is not None
if is_match and pattern_type == 'include':
file_status = (file_info, True)
LOG.debug("%s matched include filter: %s", file_path, path_pattern)
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/customizations/s3/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,33 @@ def test_create_filter_s3_to_s3(self):
for filtered_file in filtered:
self.assertFalse('.txt' in filtered_file.src)

def test_reuses_filter_across_src_types(self):
# Patterns are compiled per source type and cached, so a filter
# reused across source types has to keep giving each type its own
# separator normalization rather than the first one it saw.
exclude_filter = self.create_filter([['exclude', '*.txt']])
for _ in range(2):
local = list(exclude_filter.call(self.local_files))
self.assertEqual(
[os.path.basename(f.src) for f in local],
['test.jpg', 'test.jpg'],
)
s3 = list(exclude_filter.call(self.s3_files))
self.assertEqual(
[f.src for f in s3], ['bucket/test.jpg', 'bucket/key/test.jpg']
)

def test_repeated_calls_are_stable(self):
# The compiled-pattern cache must not accumulate or mutate state
# between calls.
include_filter = self.create_filter(
[['exclude', '*'], ['include', '*.jpg']]
)
first = [f.src for f in include_filter.call(self.local_files)]
second = [f.src for f in include_filter.call(self.local_files)]
self.assertEqual(first, second)
self.assertEqual(len(first), 2)


if __name__ == "__main__":
unittest.main()