Skip to content
Closed
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%."
}
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-startup-22805.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "startup",
"description": "Defer importing ``docutils`` until help output is actually rendered. It was previously imported on every CLI invocation, along with ``pygments`` and ``PIL``, even for commands that never render help."
}
13 changes: 8 additions & 5 deletions awscli/clidriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,6 @@
)
from awscli.formatter import get_formatter
from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS
from awscli.help import (
OperationHelpCommand,
ProviderHelpCommand,
ServiceHelpCommand,
)
from awscli.lazy_emitter import LazyInitEmitter
from awscli.logger import (
disable_crt_logging,
Expand Down Expand Up @@ -534,6 +529,10 @@ def _create_cli_argument(self, option_name, option_params):
)

def create_help_command(self):
# Imported here because the help machinery pulls in docutils,
# which is only needed when help is actually requested.
from awscli.help import ProviderHelpCommand

cli_data = self._get_cli_data()
return ProviderHelpCommand(
self.session,
Expand Down Expand Up @@ -769,6 +768,8 @@ def _add_lineage(self, command_table):
command_obj.lineage = self.lineage + [command_obj]

def create_help_command(self):
from awscli.help import ServiceHelpCommand

command_table = self._get_command_table()
return ServiceHelpCommand(
session=self.session,
Expand Down Expand Up @@ -964,6 +965,8 @@ def __call__(self, args, parsed_globals):
)

def create_help_command(self):
from awscli.help import OperationHelpCommand

return OperationHelpCommand(
self._session,
operation_model=self._operation_model,
Expand Down
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
16 changes: 11 additions & 5 deletions awscli/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@
from subprocess import PIPE, Popen

from botocore.exceptions import ProfileNotFound
from docutils.core import publish_string
from docutils.writers import (
html4css1,
manpage,
)

from awscli import (
_DEFAULT_BASE_REMOTE_URL,
Expand Down Expand Up @@ -239,6 +234,9 @@ class PosixHelpRenderer(PosixPagingHelpRenderer):
"""

def _convert_doc_content(self, contents):
from docutils.core import publish_string
from docutils.writers import manpage

settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy()
settings_overrides["report_level"] = 3
man_contents = publish_string(
Expand All @@ -265,6 +263,9 @@ class PosixBrowserHelpRenderer(BrowserHelpRenderer):
"""

def _convert_doc_content(self, contents):
from docutils.core import publish_string
from docutils.writers import manpage

settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy()
settings_overrides["report_level"] = 3
man_contents = publish_string(
Expand Down Expand Up @@ -310,6 +311,8 @@ class WindowsHelpRenderer(WindowsPagingHelpRenderer):
"""Render help content on a Windows platform."""

def _convert_doc_content(self, contents):
from docutils.core import publish_string

text_output = publish_string(
contents,
writer=TextWriter(),
Expand All @@ -322,6 +325,9 @@ class WindowsBrowserHelpRenderer(BrowserHelpRenderer):
"""Render help content in the browser on a Windows platform."""

def _convert_doc_content(self, contents):
from docutils.core import publish_string
from docutils.writers import html4css1

text_output = publish_string(
contents,
writer=html4css1.Writer(),
Expand Down
7 changes: 4 additions & 3 deletions awscli/topictags.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@
import json
import os

import docutils.core


class TopicTagDB:
"""This class acts like a database for the tags of all available topics.
Expand Down Expand Up @@ -182,7 +180,10 @@ def _find_topic_name(self, topic_src_file):

def _add_tag_and_values_from_content(self, topic_name, content):
# Retrieves tags and values and adds from content of topic file
# to the dictionary.
# to the dictionary. Imported here because docutils is only
# needed when the topic index is (re)generated or queried.
import docutils.core

doctree = docutils.core.publish_doctree(content).asdom()
fields = doctree.getElementsByTagName('field')
for field in fields:
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()