Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
aws
GitHub Repository: aws/aws-cli
Path: blob/develop/tests/functional/docs/test_examples.py
1567 views
1
#!/usr/bin/env python
2
# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
#
4
# Licensed under the Apache License, Version 2.0 (the "License"). You
5
# may not use this file except in compliance with the License. A copy of
6
# the License is located at
7
#
8
# http://aws.amazon.com/apache2.0/
9
#
10
# or in the "license" file accompanying this file. This file is
11
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
12
# ANY KIND, either express or implied. See the License for the specific
13
# language governing permissions and limitations under the License.
14
"""Test help output for the AWS CLI.
15
16
The purpose of these docs is to test that the generated output looks how
17
we expect.
18
19
It's intended to be as end to end as possible, but instead of looking
20
at the man output, we look one step before at the generated rst output
21
(it's easier to verify).
22
23
"""
24
import os
25
import re
26
import shlex
27
import docutils.nodes
28
import docutils.parsers.rst
29
import docutils.utils
30
31
import pytest
32
33
from awscli.argparser import MainArgParser
34
from awscli.argparser import ServiceArgParser
35
from awscli.testutils import BaseAWSHelpOutputTest, create_clidriver
36
37
38
# Mapping of command names to subcommands that have examples in their help
39
# output. This isn't mean to be an exhaustive list, but should help catch
40
# things like command table renames, virtual commands, etc.
41
COMMAND_EXAMPLES = {
42
'cloudwatch': ['put-metric-data'],
43
's3': ['cp', 'ls', 'mb', 'mv', 'rb', 'rm', 'sync'],
44
's3api': ['get-object', 'put-object'],
45
'ec2': ['run-instances', 'start-instances', 'stop-instances'],
46
'swf': ['deprecate-domain', 'describe-domain'],
47
'sqs': ['create-queue', 'get-queue-attributes'],
48
'emr': ['add-steps', 'create-default-roles', 'describe-cluster', 'schedule-hbase-backup'],
49
}
50
_dname = os.path.dirname
51
EXAMPLES_DIR = os.path.join(
52
_dname(_dname(_dname(_dname(os.path.abspath(__file__))))),
53
'awscli', 'examples')
54
55
ALLOWED_FILENAME_CHAR_REGEX = re.compile(r'([a-z0-9_\-\.]*$)')
56
HTTP_LINK_REGEX = re.compile(r'`.+?<http://')
57
58
59
# Used so that docutils doesn't write errors to stdout/stderr.
60
# We're collecting and reporting these via AssertionErrors messages.
61
class NoopWriter(object):
62
def write(self, *args, **kwargs):
63
pass
64
65
66
class _ExampleTests(BaseAWSHelpOutputTest):
67
def noop_test(self):
68
pass
69
70
71
def _get_example_test_cases():
72
test_cases = []
73
for command, subcommands in COMMAND_EXAMPLES.items():
74
for subcommand in subcommands:
75
test_cases.append((command, subcommand))
76
return test_cases
77
78
79
def _get_all_doc_examples():
80
rst_doc_examples = []
81
other_doc_examples = []
82
# Iterate over all rst doc examples
83
for root, _, filenames in os.walk(EXAMPLES_DIR):
84
for filename in filenames:
85
full_path = os.path.join(root, filename)
86
if filename.startswith('.'):
87
# Ignore hidden files as it starts with "."
88
continue
89
if not filename.endswith('.rst'):
90
other_doc_examples.append(full_path)
91
continue
92
rst_doc_examples.append(full_path)
93
return rst_doc_examples, other_doc_examples
94
95
96
RST_DOC_EXAMPLES, OTHER_DOC_EXAMPLES = _get_all_doc_examples()
97
EXAMPLE_COMMAND_TESTS = _get_example_test_cases()
98
99
100
def line_num(content, loc):
101
return content[:loc].count('\n') + 1
102
103
104
def extract_error_line(content, error_start, error_end):
105
error_line_begin = content.rfind('\n', 0, error_start)
106
error_line_end = content.find('\n', error_end)
107
return content[error_line_begin:error_line_end]
108
109
110
@pytest.fixture(scope="module")
111
def command_validator():
112
# CLIDriver can take up a lot of resources so we'll just create one
113
# instance and use it for all the validation tests.
114
driver = create_clidriver()
115
return CommandValidator(driver)
116
117
118
@pytest.mark.parametrize(
119
"command, subcommand",
120
EXAMPLE_COMMAND_TESTS
121
)
122
def test_examples(command, subcommand):
123
t = _ExampleTests(methodName='noop_test')
124
t.setUp()
125
try:
126
t.driver.main([command, subcommand, 'help'])
127
t.assert_contains_with_count('========\nExamples\n========', 1)
128
finally:
129
t.tearDown()
130
131
132
@pytest.mark.parametrize(
133
"example_file",
134
RST_DOC_EXAMPLES
135
)
136
def test_rst_doc_examples(command_validator, example_file):
137
verify_has_only_ascii_chars(example_file)
138
verify_is_valid_rst(example_file)
139
verify_cli_commands_valid(example_file, command_validator)
140
verify_no_http_links(example_file)
141
142
143
def verify_no_http_links(filename):
144
with open(filename) as f:
145
contents = f.read()
146
match = HTTP_LINK_REGEX.search(contents)
147
if match:
148
error_line_number = line_num(contents, match.span()[0])
149
error_line = extract_error_line(
150
contents, match.span()[0], match.span()[1])
151
marker_idx = error_line.find('http://') - 1
152
marker_line = (" " * marker_idx) + '^'
153
raise AssertionError(
154
'Found http:// link in the examples file %s, line %s\n'
155
'%s\n%s' % (filename, error_line_number, error_line, marker_line)
156
)
157
158
159
def verify_has_only_ascii_chars(filename):
160
with open(filename, 'rb') as f:
161
bytes_content = f.read()
162
try:
163
bytes_content.decode('ascii')
164
except UnicodeDecodeError as e:
165
# The test has failed so we'll try to provide a useful error
166
# message.
167
offset = e.start
168
spread = 20
169
bad_text = bytes_content[offset-spread:e.start+spread]
170
underlined = ' ' * spread + '^'
171
error_text = '\n'.join([bad_text, underlined])
172
line_number = bytes_content[:offset].count(b'\n') + 1
173
raise AssertionError(
174
"Non ascii characters found in the examples file %s, line %s:"
175
"\n\n%s\n" % (filename, line_number, error_text))
176
177
178
def verify_is_valid_rst(filename):
179
_, errors = parse_rst(filename)
180
if errors:
181
raise AssertionError(_make_error_msg(filename, errors))
182
183
184
def parse_rst(filename):
185
with open(filename) as f:
186
contents = f.read()
187
parser = docutils.parsers.rst.Parser()
188
components = (docutils.parsers.rst.Parser,)
189
settings = docutils.frontend.OptionParser(
190
components=components).get_default_values()
191
document = docutils.utils.new_document('<cli-example>', settings=settings)
192
errors = []
193
194
def record_errors(msg):
195
msg.level = msg['level']
196
msg.type = msg['type']
197
error_message = docutils.nodes.Element.astext(msg.children[0])
198
line_number = msg['line']
199
errors.append({'msg': error_message, 'line_number': line_number})
200
201
document.reporter.stream = NoopWriter()
202
document.reporter.attach_observer(record_errors)
203
parser.parse(contents, document)
204
return document, errors
205
206
207
def _make_error_msg(filename, errors):
208
with open(filename) as f:
209
lines = f.readlines()
210
relative_name = filename[len(EXAMPLES_DIR) + 1:]
211
failure_message = [
212
'The file "%s" contains invalid RST: ' % relative_name,
213
'',
214
]
215
for error in errors:
216
# This may not always be super helpful because you sometimes need
217
# more than one line of context to understand what went wrong,
218
# but by giving you the filename and the line number, it's usually
219
# enough to track down what went wrong.
220
line_number = min(error['line_number'], len(lines))
221
line_number -= 1
222
if line_number > 0:
223
line_number -= 1
224
current_message = [
225
'Line %s: %s' % (error['line_number'], error['msg']),
226
' %s' % lines[line_number],
227
]
228
failure_message.extend(current_message)
229
return '\n'.join(failure_message)
230
231
232
def verify_cli_commands_valid(filename, command_validator):
233
cli_commands = find_all_cli_commands(filename)
234
for command in cli_commands:
235
command_validator.validate_cli_command(command, filename)
236
237
238
def find_all_cli_commands(filename):
239
document, _ = parse_rst(filename)
240
visitor = CollectCLICommands(document)
241
document.walk(visitor)
242
return visitor.cli_commands
243
244
245
class CommandValidator(object):
246
def __init__(self, driver):
247
self.driver = driver
248
help_command = self.driver.create_help_command()
249
self._service_command_table = help_command.command_table
250
self._global_arg_table = help_command.arg_table
251
self._main_parser = MainArgParser(
252
self._service_command_table, driver.session.user_agent(),
253
'Some description',
254
self._global_arg_table,
255
prog="aws"
256
)
257
258
def validate_cli_command(self, command, filename):
259
# The plan is to expand this to use the proper CLI parser and
260
# parse arguments and verify them with the service model, but
261
# as a first pass, we're going to verify that the service name
262
# and operation match what we expect. We can do this without
263
# having to use a parser.
264
self._parse_service_operation(command, filename)
265
266
def _parse_service_operation(self, command, filename):
267
try:
268
command_parts = shlex.split(command)[1:]
269
except Exception as e:
270
raise AssertionError(
271
"Failed to parse this example as shell command: %s\n\n"
272
"Error:\n%s\n" % (command, e)
273
)
274
# Strip off the 'aws ' part and break it out into a list.
275
parsed_args, remaining = self._parse_next_command(
276
filename, command, command_parts, self._main_parser)
277
# We know the service is good. Parse the operation.
278
cmd = self._service_command_table[parsed_args.command]
279
cmd_table = cmd.create_help_command().command_table
280
service_parser = ServiceArgParser(operations_table=cmd_table,
281
service_name=parsed_args.command)
282
self._parse_next_command(filename, command, remaining, service_parser)
283
284
def _parse_next_command(self, filename, original_cmd, args_list, parser):
285
# Strip off the 'aws ' part and break it out into a list.
286
errors = []
287
parser._print_message = lambda message, file: errors.append(
288
message)
289
try:
290
parsed_args, remaining = parser.parse_known_args(args_list)
291
return parsed_args, remaining
292
except SystemExit:
293
# Yes...we have to catch SystemExit. argparse raises this
294
# when you have an invalid command.
295
error_msg = [
296
'Invalid CLI command: %s\n\n' % original_cmd,
297
]
298
if errors:
299
error_msg.extend(errors)
300
raise AssertionError(''.join(error_msg))
301
302
303
class CollectCLICommands(docutils.nodes.GenericNodeVisitor):
304
def __init__(self, document):
305
docutils.nodes.GenericNodeVisitor.__init__(self, document)
306
self.cli_commands = []
307
308
def visit_literal_block(self, node):
309
contents = node.rawsource.strip()
310
if contents.startswith('aws '):
311
self.cli_commands.append(contents)
312
313
def default_visit(self, node):
314
pass
315
316
317
@pytest.mark.parametrize(
318
"example_file",
319
RST_DOC_EXAMPLES + OTHER_DOC_EXAMPLES
320
)
321
def test_example_file_name(example_file):
322
filename = example_file.split(os.sep)[-1]
323
_assert_file_is_rst_or_txt(example_file)
324
_assert_name_contains_only_allowed_characters(filename)
325
326
327
def _assert_file_is_rst_or_txt(filepath):
328
assert filepath.endswith('.rst') or filepath.endswith('.txt')
329
330
331
def _assert_name_contains_only_allowed_characters(filename):
332
assert ALLOWED_FILENAME_CHAR_REGEX.match(filename)
333
334