mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
Add test verification to ansible-test. (#22636)
* Add unified git diff parser. * Add metadata and diff handling. * Add test confidence/verification to bot output.
This commit is contained in:
parent
5e9a2b8528
commit
869449e288
14 changed files with 623 additions and 10 deletions
12
test/runner/Makefile
Normal file
12
test/runner/Makefile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.PHONY: all compile sanity units
|
||||||
|
|
||||||
|
all: compile sanity units
|
||||||
|
|
||||||
|
compile:
|
||||||
|
./ansible-test compile test/runner/ ${TEST_FLAGS}
|
||||||
|
|
||||||
|
sanity:
|
||||||
|
./ansible-test sanity test/runner/ ${TEST_FLAGS}
|
||||||
|
|
||||||
|
units:
|
||||||
|
PYTHONPATH=. pytest units ${TEST_FLAGS}
|
|
@ -69,15 +69,18 @@ class ShippableChanges(object):
|
||||||
|
|
||||||
if self.is_pr:
|
if self.is_pr:
|
||||||
self.paths = sorted(git.get_diff_names([self.branch]))
|
self.paths = sorted(git.get_diff_names([self.branch]))
|
||||||
|
self.diff = git.get_diff([self.branch])
|
||||||
else:
|
else:
|
||||||
merge_runs = self.get_merge_runs(self.project_id, self.branch)
|
merge_runs = self.get_merge_runs(self.project_id, self.branch)
|
||||||
last_successful_commit = self.get_last_successful_commit(git, merge_runs)
|
last_successful_commit = self.get_last_successful_commit(git, merge_runs)
|
||||||
|
|
||||||
if last_successful_commit:
|
if last_successful_commit:
|
||||||
self.paths = sorted(git.get_diff_names([last_successful_commit, self.commit]))
|
self.paths = sorted(git.get_diff_names([last_successful_commit, self.commit]))
|
||||||
|
self.diff = git.get_diff([last_successful_commit, self.commit])
|
||||||
else:
|
else:
|
||||||
# tracked files (including unchanged)
|
# tracked files (including unchanged)
|
||||||
self.paths = sorted(git.get_file_names(['--cached']))
|
self.paths = sorted(git.get_file_names(['--cached']))
|
||||||
|
self.diff = None
|
||||||
|
|
||||||
def get_merge_runs(self, project_id, branch):
|
def get_merge_runs(self, project_id, branch):
|
||||||
"""
|
"""
|
||||||
|
@ -161,6 +164,8 @@ class LocalChanges(object):
|
||||||
self.staged = sorted(git.get_diff_names(['--cached']))
|
self.staged = sorted(git.get_diff_names(['--cached']))
|
||||||
# tracked changes (including deletions) which are not staged
|
# tracked changes (including deletions) which are not staged
|
||||||
self.unstaged = sorted(git.get_diff_names([]))
|
self.unstaged = sorted(git.get_diff_names([]))
|
||||||
|
# diff of all tracked files from fork point to working copy
|
||||||
|
self.diff = git.get_diff([self.fork_point])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_official_branch(name):
|
def is_official_branch(name):
|
||||||
|
|
|
@ -4,6 +4,7 @@ from __future__ import absolute_import, print_function
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import lib.pytar
|
import lib.pytar
|
||||||
|
@ -46,6 +47,27 @@ def delegate(args, exclude, require):
|
||||||
:type args: EnvironmentConfig
|
:type args: EnvironmentConfig
|
||||||
:type exclude: list[str]
|
:type exclude: list[str]
|
||||||
:type require: list[str]
|
:type require: list[str]
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
if isinstance(args, TestConfig):
|
||||||
|
with tempfile.NamedTemporaryFile(prefix='metadata-', suffix='.json', dir=os.getcwd()) as metadata_fd:
|
||||||
|
args.metadata_path = os.path.basename(metadata_fd.name)
|
||||||
|
args.metadata.to_file(args.metadata_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return delegate_command(args, exclude, require)
|
||||||
|
finally:
|
||||||
|
args.metadata_path = None
|
||||||
|
else:
|
||||||
|
return delegate_command(args, exclude, require)
|
||||||
|
|
||||||
|
|
||||||
|
def delegate_command(args, exclude, require):
|
||||||
|
"""
|
||||||
|
:type args: EnvironmentConfig
|
||||||
|
:type exclude: list[str]
|
||||||
|
:type require: list[str]
|
||||||
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
if args.tox:
|
if args.tox:
|
||||||
delegate_tox(args, exclude, require)
|
delegate_tox(args, exclude, require)
|
||||||
|
@ -396,6 +418,7 @@ def filter_options(args, argv, options, exclude, require):
|
||||||
'--ignore-unstaged': 0,
|
'--ignore-unstaged': 0,
|
||||||
'--changed-from': 1,
|
'--changed-from': 1,
|
||||||
'--changed-path': 1,
|
'--changed-path': 1,
|
||||||
|
'--metadata': 1,
|
||||||
})
|
})
|
||||||
elif isinstance(args, SanityConfig):
|
elif isinstance(args, SanityConfig):
|
||||||
options.update({
|
options.update({
|
||||||
|
@ -427,3 +450,7 @@ def filter_options(args, argv, options, exclude, require):
|
||||||
for target in require:
|
for target in require:
|
||||||
yield '--require'
|
yield '--require'
|
||||||
yield target
|
yield target
|
||||||
|
|
||||||
|
if args.metadata_path:
|
||||||
|
yield '--metadata'
|
||||||
|
yield args.metadata_path
|
||||||
|
|
253
test/runner/lib/diff.py
Normal file
253
test/runner/lib/diff.py
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
"""Diff parsing functions and classes."""
|
||||||
|
from __future__ import absolute_import, print_function
|
||||||
|
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from lib.util import (
|
||||||
|
ApplicationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_diff(lines):
|
||||||
|
"""
|
||||||
|
:type lines: list[str]
|
||||||
|
:rtype: list[FileDiff]
|
||||||
|
"""
|
||||||
|
return DiffParser(lines).files
|
||||||
|
|
||||||
|
|
||||||
|
class FileDiff(object):
|
||||||
|
"""Parsed diff for a single file."""
|
||||||
|
def __init__(self, old_path, new_path):
|
||||||
|
"""
|
||||||
|
:type old_path: str
|
||||||
|
:type new_path: str
|
||||||
|
"""
|
||||||
|
self.old = DiffSide(old_path, new=False)
|
||||||
|
self.new = DiffSide(new_path, new=True)
|
||||||
|
self.headers = [] # list [str]
|
||||||
|
self.binary = False
|
||||||
|
|
||||||
|
def append_header(self, line):
|
||||||
|
"""
|
||||||
|
:type line: str
|
||||||
|
"""
|
||||||
|
self.headers.append(line)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_complete(self):
|
||||||
|
"""
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return self.old.is_complete and self.new.is_complete
|
||||||
|
|
||||||
|
|
||||||
|
class DiffSide(object):
|
||||||
|
"""Parsed diff for a single 'side' of a single file."""
|
||||||
|
def __init__(self, path, new):
|
||||||
|
"""
|
||||||
|
:type path: str
|
||||||
|
:type new: bool
|
||||||
|
"""
|
||||||
|
self.path = path
|
||||||
|
self.new = new
|
||||||
|
self.prefix = '+' if self.new else '-'
|
||||||
|
self.eof_newline = True
|
||||||
|
self.exists = True
|
||||||
|
|
||||||
|
self.lines = [] # type: list [tuple[int, str]]
|
||||||
|
self.lines_and_context = [] # type: list [tuple[int, str]]
|
||||||
|
self.ranges = [] # type: list [tuple[int, int]]
|
||||||
|
|
||||||
|
self._next_line_number = 0
|
||||||
|
self._lines_remaining = 0
|
||||||
|
self._range_start = 0
|
||||||
|
|
||||||
|
def set_start(self, line_start, line_count):
|
||||||
|
"""
|
||||||
|
:type line_start: int
|
||||||
|
:type line_count: int
|
||||||
|
"""
|
||||||
|
self._next_line_number = line_start
|
||||||
|
self._lines_remaining = line_count
|
||||||
|
self._range_start = 0
|
||||||
|
|
||||||
|
def append(self, line):
|
||||||
|
"""
|
||||||
|
:type line: str
|
||||||
|
"""
|
||||||
|
if self._lines_remaining <= 0:
|
||||||
|
raise Exception('Diff range overflow.')
|
||||||
|
|
||||||
|
entry = self._next_line_number, line
|
||||||
|
|
||||||
|
if line.startswith(' '):
|
||||||
|
pass
|
||||||
|
elif line.startswith(self.prefix):
|
||||||
|
self.lines.append(entry)
|
||||||
|
|
||||||
|
if not self._range_start:
|
||||||
|
self._range_start = self._next_line_number
|
||||||
|
else:
|
||||||
|
raise Exception('Unexpected diff content prefix.')
|
||||||
|
|
||||||
|
self.lines_and_context.append(entry)
|
||||||
|
|
||||||
|
self._lines_remaining -= 1
|
||||||
|
|
||||||
|
if self._range_start:
|
||||||
|
if self.is_complete:
|
||||||
|
range_end = self._next_line_number
|
||||||
|
elif line.startswith(' '):
|
||||||
|
range_end = self._next_line_number - 1
|
||||||
|
else:
|
||||||
|
range_end = 0
|
||||||
|
|
||||||
|
if range_end:
|
||||||
|
self.ranges.append((self._range_start, range_end))
|
||||||
|
self._range_start = 0
|
||||||
|
|
||||||
|
self._next_line_number += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_complete(self):
|
||||||
|
"""
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return self._lines_remaining == 0
|
||||||
|
|
||||||
|
def format_lines(self, context=True):
|
||||||
|
"""
|
||||||
|
:type context: bool
|
||||||
|
:rtype: list[str]
|
||||||
|
"""
|
||||||
|
if context:
|
||||||
|
lines = self.lines_and_context
|
||||||
|
else:
|
||||||
|
lines = self.lines
|
||||||
|
|
||||||
|
return ['%s:%4d %s' % (self.path, line[0], line[1]) for line in lines]
|
||||||
|
|
||||||
|
|
||||||
|
class DiffParser(object):
|
||||||
|
"""Parse diff lines."""
|
||||||
|
def __init__(self, lines):
|
||||||
|
"""
|
||||||
|
:type lines: list[str]
|
||||||
|
"""
|
||||||
|
self.lines = lines
|
||||||
|
self.files = [] # type: list [FileDiff]
|
||||||
|
|
||||||
|
self.action = self.process_start
|
||||||
|
self.line_number = 0
|
||||||
|
self.previous_line = None # type: str
|
||||||
|
self.line = None # type: str
|
||||||
|
self.file = None # type: FileDiff
|
||||||
|
|
||||||
|
for self.line in self.lines:
|
||||||
|
self.line_number += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.action()
|
||||||
|
except Exception as ex:
|
||||||
|
message = textwrap.dedent('''
|
||||||
|
%s
|
||||||
|
|
||||||
|
Line: %d
|
||||||
|
Previous: %s
|
||||||
|
Current: %s
|
||||||
|
%s
|
||||||
|
''').strip() % (
|
||||||
|
ex,
|
||||||
|
self.line_number,
|
||||||
|
self.previous_line or '',
|
||||||
|
self.line or '',
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ApplicationError(message.strip())
|
||||||
|
|
||||||
|
self.previous_line = self.line
|
||||||
|
|
||||||
|
self.complete_file()
|
||||||
|
|
||||||
|
def process_start(self):
|
||||||
|
"""Process a diff start line."""
|
||||||
|
self.complete_file()
|
||||||
|
|
||||||
|
match = re.search(r'^diff --git a/(?P<old_path>.*) b/(?P<new_path>.*)$', self.line)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
raise Exception('Unexpected diff start line.')
|
||||||
|
|
||||||
|
self.file = FileDiff(match.group('old_path'), match.group('new_path'))
|
||||||
|
self.action = self.process_continue
|
||||||
|
|
||||||
|
def process_range(self):
|
||||||
|
"""Process a diff range line."""
|
||||||
|
match = re.search(r'^@@ -((?P<old_start>[0-9]+),)?(?P<old_count>[0-9]+) \+((?P<new_start>[0-9]+),)?(?P<new_count>[0-9]+) @@', self.line)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
raise Exception('Unexpected diff range line.')
|
||||||
|
|
||||||
|
self.file.old.set_start(int(match.group('old_start') or 1), int(match.group('old_count')))
|
||||||
|
self.file.new.set_start(int(match.group('new_start') or 1), int(match.group('new_count')))
|
||||||
|
self.action = self.process_content
|
||||||
|
|
||||||
|
def process_continue(self):
|
||||||
|
"""Process a diff start, range or header line."""
|
||||||
|
if self.line.startswith('diff '):
|
||||||
|
self.process_start()
|
||||||
|
elif self.line.startswith('@@ '):
|
||||||
|
self.process_range()
|
||||||
|
else:
|
||||||
|
self.process_header()
|
||||||
|
|
||||||
|
def process_header(self):
|
||||||
|
"""Process a diff header line."""
|
||||||
|
if self.line.startswith('Binary files '):
|
||||||
|
self.file.binary = True
|
||||||
|
elif self.line == '--- /dev/null':
|
||||||
|
self.file.old.exists = False
|
||||||
|
elif self.line == '+++ /dev/null':
|
||||||
|
self.file.new.exists = False
|
||||||
|
else:
|
||||||
|
self.file.append_header(self.line)
|
||||||
|
|
||||||
|
def process_content(self):
|
||||||
|
"""Process a diff content line."""
|
||||||
|
if self.line == r'\ No newline at end of file':
|
||||||
|
if self.previous_line.startswith(' '):
|
||||||
|
self.file.old.eof_newline = False
|
||||||
|
self.file.new.eof_newline = False
|
||||||
|
elif self.previous_line.startswith('-'):
|
||||||
|
self.file.old.eof_newline = False
|
||||||
|
elif self.previous_line.startswith('+'):
|
||||||
|
self.file.new.eof_newline = False
|
||||||
|
else:
|
||||||
|
raise Exception('Unexpected previous diff content line.')
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.file.is_complete:
|
||||||
|
self.process_continue()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.line.startswith(' '):
|
||||||
|
self.file.old.append(self.line)
|
||||||
|
self.file.new.append(self.line)
|
||||||
|
elif self.line.startswith('-'):
|
||||||
|
self.file.old.append(self.line)
|
||||||
|
elif self.line.startswith('+'):
|
||||||
|
self.file.new.append(self.line)
|
||||||
|
else:
|
||||||
|
raise Exception('Unexpected diff content line.')
|
||||||
|
|
||||||
|
def complete_file(self):
|
||||||
|
"""Complete processing of the current file, if any."""
|
||||||
|
if not self.file:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.files.append(self.file)
|
|
@ -41,6 +41,7 @@ from lib.util import (
|
||||||
remove_tree,
|
remove_tree,
|
||||||
make_dirs,
|
make_dirs,
|
||||||
is_shippable,
|
is_shippable,
|
||||||
|
is_binary_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lib.test import (
|
from lib.test import (
|
||||||
|
@ -82,6 +83,10 @@ from lib.test import (
|
||||||
TestSkipped,
|
TestSkipped,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.metadata import (
|
||||||
|
Metadata,
|
||||||
|
)
|
||||||
|
|
||||||
SUPPORTED_PYTHON_VERSIONS = (
|
SUPPORTED_PYTHON_VERSIONS = (
|
||||||
'2.6',
|
'2.6',
|
||||||
'2.7',
|
'2.7',
|
||||||
|
@ -919,7 +924,7 @@ def detect_changes(args):
|
||||||
|
|
||||||
def detect_changes_shippable(args):
|
def detect_changes_shippable(args):
|
||||||
"""Initialize change detection on Shippable.
|
"""Initialize change detection on Shippable.
|
||||||
:type args: CommonConfig
|
:type args: TestConfig
|
||||||
:rtype: list[str]
|
:rtype: list[str]
|
||||||
"""
|
"""
|
||||||
git = Git(args)
|
git = Git(args)
|
||||||
|
@ -934,6 +939,9 @@ def detect_changes_shippable(args):
|
||||||
|
|
||||||
display.info('Processing %s for branch %s commit %s' % (job_type, result.branch, result.commit))
|
display.info('Processing %s for branch %s commit %s' % (job_type, result.branch, result.commit))
|
||||||
|
|
||||||
|
if not args.metadata.changes:
|
||||||
|
args.metadata.populate_changes(result.diff)
|
||||||
|
|
||||||
return result.paths
|
return result.paths
|
||||||
|
|
||||||
|
|
||||||
|
@ -977,6 +985,19 @@ def detect_changes_local(args):
|
||||||
if args.unstaged:
|
if args.unstaged:
|
||||||
names |= set(result.unstaged)
|
names |= set(result.unstaged)
|
||||||
|
|
||||||
|
if not args.metadata.changes:
|
||||||
|
args.metadata.populate_changes(result.diff)
|
||||||
|
|
||||||
|
for path in result.untracked:
|
||||||
|
if is_binary_file(path):
|
||||||
|
args.metadata.changes[path] = ((0, 0),)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(path, 'r') as source_fd:
|
||||||
|
line_count = len(source_fd.read().splitlines())
|
||||||
|
|
||||||
|
args.metadata.changes[path] = ((1, line_count),)
|
||||||
|
|
||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,14 @@ class Git(object):
|
||||||
self.args = args
|
self.args = args
|
||||||
self.git = 'git'
|
self.git = 'git'
|
||||||
|
|
||||||
|
def get_diff(self, args):
|
||||||
|
"""
|
||||||
|
:type args: list[str]
|
||||||
|
:rtype: list[str]
|
||||||
|
"""
|
||||||
|
cmd = ['diff'] + args
|
||||||
|
return self.run_git_split(cmd, '\n')
|
||||||
|
|
||||||
def get_diff_names(self, args):
|
def get_diff_names(self, args):
|
||||||
"""
|
"""
|
||||||
:type args: list[str]
|
:type args: list[str]
|
||||||
|
|
82
test/runner/lib/metadata.py
Normal file
82
test/runner/lib/metadata.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
"""Test metadata for passing data to delegated tests."""
|
||||||
|
from __future__ import absolute_import, print_function
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from lib.util import (
|
||||||
|
display,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lib.diff import (
|
||||||
|
parse_diff,
|
||||||
|
FileDiff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata(object):
|
||||||
|
"""Metadata object for passing data to delegated tests."""
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize metadata."""
|
||||||
|
self.changes = {} # type: dict [str, tuple[tuple[int, int]]
|
||||||
|
|
||||||
|
def populate_changes(self, diff):
|
||||||
|
"""
|
||||||
|
:type diff: list[str] | None
|
||||||
|
"""
|
||||||
|
patches = parse_diff(diff)
|
||||||
|
patches = sorted(patches, key=lambda k: k.new.path) # type: list [FileDiff]
|
||||||
|
|
||||||
|
self.changes = dict((patch.new.path, tuple(patch.new.ranges)) for patch in patches)
|
||||||
|
|
||||||
|
renames = [patch.old.path for patch in patches if patch.old.path != patch.new.path and patch.old.exists and patch.new.exists]
|
||||||
|
deletes = [patch.old.path for patch in patches if not patch.new.exists]
|
||||||
|
|
||||||
|
# make sure old paths which were renamed or deleted are registered in changes
|
||||||
|
for path in renames + deletes:
|
||||||
|
if path in self.changes:
|
||||||
|
# old path was replaced with another file
|
||||||
|
continue
|
||||||
|
|
||||||
|
# failed tests involving deleted files should be using line 0 since there is no content remaining
|
||||||
|
self.changes[path] = ((0, 0),)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""
|
||||||
|
:rtype: dict[str, any]
|
||||||
|
"""
|
||||||
|
return dict(
|
||||||
|
changes=self.changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_file(self, path):
|
||||||
|
"""
|
||||||
|
:type path: path
|
||||||
|
"""
|
||||||
|
data = self.to_dict()
|
||||||
|
|
||||||
|
display.info('>>> Metadata: %s\n%s' % (path, data), verbosity=3)
|
||||||
|
|
||||||
|
with open(path, 'w') as data_fd:
|
||||||
|
json.dump(data, data_fd, sort_keys=True, indent=4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_file(path):
|
||||||
|
"""
|
||||||
|
:type path: str
|
||||||
|
:rtype: Metadata
|
||||||
|
"""
|
||||||
|
with open(path, 'r') as data_fd:
|
||||||
|
data = json.load(data_fd)
|
||||||
|
|
||||||
|
return Metadata.from_dict(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data):
|
||||||
|
"""
|
||||||
|
:type data: dict[str, any]
|
||||||
|
:rtype: Metadata
|
||||||
|
"""
|
||||||
|
metadata = Metadata()
|
||||||
|
metadata.changes = data['changes']
|
||||||
|
|
||||||
|
return metadata
|
|
@ -45,6 +45,7 @@ from lib.test import (
|
||||||
TestFailure,
|
TestFailure,
|
||||||
TestSkipped,
|
TestSkipped,
|
||||||
TestMessage,
|
TestMessage,
|
||||||
|
calculate_best_confidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
COMMAND = 'sanity'
|
COMMAND = 'sanity'
|
||||||
|
@ -364,6 +365,7 @@ def command_sanity_pep8(args, targets):
|
||||||
path=PEP8_LEGACY_PATH,
|
path=PEP8_LEGACY_PATH,
|
||||||
line=line,
|
line=line,
|
||||||
column=1,
|
column=1,
|
||||||
|
confidence=calculate_best_confidence(((PEP8_LEGACY_PATH, line), (path, 0)), args.metadata) if args.metadata.changes else None,
|
||||||
))
|
))
|
||||||
|
|
||||||
if path in used_paths and path not in failed_result_paths:
|
if path in used_paths and path not in failed_result_paths:
|
||||||
|
@ -374,6 +376,7 @@ def command_sanity_pep8(args, targets):
|
||||||
path=PEP8_LEGACY_PATH,
|
path=PEP8_LEGACY_PATH,
|
||||||
line=line,
|
line=line,
|
||||||
column=1,
|
column=1,
|
||||||
|
confidence=calculate_best_confidence(((PEP8_LEGACY_PATH, line), (path, 0)), args.metadata) if args.metadata.changes else None,
|
||||||
))
|
))
|
||||||
|
|
||||||
line = 0
|
line = 0
|
||||||
|
@ -389,6 +392,7 @@ def command_sanity_pep8(args, targets):
|
||||||
path=PEP8_SKIP_PATH,
|
path=PEP8_SKIP_PATH,
|
||||||
line=line,
|
line=line,
|
||||||
column=1,
|
column=1,
|
||||||
|
confidence=calculate_best_confidence(((PEP8_SKIP_PATH, line), (path, 0)), args.metadata) if args.metadata.changes else None,
|
||||||
))
|
))
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
|
@ -586,7 +590,7 @@ class SanityFailure(TestFailure):
|
||||||
|
|
||||||
class SanityMessage(TestMessage):
|
class SanityMessage(TestMessage):
|
||||||
"""Single sanity test message for one file."""
|
"""Single sanity test message for one file."""
|
||||||
def __init__(self, message, path, line=0, column=0, level='error', code=None):
|
def __init__(self, message, path, line=0, column=0, level='error', code=None, confidence=None):
|
||||||
"""
|
"""
|
||||||
:type message: str
|
:type message: str
|
||||||
:type path: str
|
:type path: str
|
||||||
|
@ -594,8 +598,9 @@ class SanityMessage(TestMessage):
|
||||||
:type column: int
|
:type column: int
|
||||||
:type level: str
|
:type level: str
|
||||||
:type code: str | None
|
:type code: str | None
|
||||||
|
:type confidence: int | None
|
||||||
"""
|
"""
|
||||||
super(SanityMessage, self).__init__(message, path, line, column, level, code)
|
super(SanityMessage, self).__init__(message, path, line, column, level, code, confidence)
|
||||||
|
|
||||||
|
|
||||||
class SanityTargets(object):
|
class SanityTargets(object):
|
||||||
|
|
|
@ -10,6 +10,50 @@ from lib.util import (
|
||||||
EnvironmentConfig,
|
EnvironmentConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.metadata import (
|
||||||
|
Metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_best_confidence(choices, metadata):
|
||||||
|
"""
|
||||||
|
:type choices: tuple[tuple[str, int]]
|
||||||
|
:type metadata: Metadata
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
best_confidence = 0
|
||||||
|
|
||||||
|
for path, line in choices:
|
||||||
|
confidence = calculate_confidence(path, line, metadata)
|
||||||
|
best_confidence = max(confidence, best_confidence)
|
||||||
|
|
||||||
|
return best_confidence
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_confidence(path, line, metadata):
|
||||||
|
"""
|
||||||
|
:type path: str
|
||||||
|
:type line: int
|
||||||
|
:type metadata: Metadata
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
ranges = metadata.changes.get(path)
|
||||||
|
|
||||||
|
# no changes were made to the file
|
||||||
|
if not ranges:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# changes were made to the same file and line
|
||||||
|
if any(r[0] <= line <= r[1] in r for r in ranges):
|
||||||
|
return 100
|
||||||
|
|
||||||
|
# changes were made to the same file and the line number is unknown
|
||||||
|
if line == 0:
|
||||||
|
return 75
|
||||||
|
|
||||||
|
# changes were made to the same file and the line number is different
|
||||||
|
return 50
|
||||||
|
|
||||||
|
|
||||||
class TestConfig(EnvironmentConfig):
|
class TestConfig(EnvironmentConfig):
|
||||||
"""Configuration common to all test commands."""
|
"""Configuration common to all test commands."""
|
||||||
|
@ -38,6 +82,9 @@ class TestConfig(EnvironmentConfig):
|
||||||
self.junit = args.junit if 'junit' in args else False # type: bool
|
self.junit = args.junit if 'junit' in args else False # type: bool
|
||||||
self.failure_ok = args.failure_ok if 'failure_ok' in args else False # type: bool
|
self.failure_ok = args.failure_ok if 'failure_ok' in args else False # type: bool
|
||||||
|
|
||||||
|
self.metadata = Metadata.from_file(args.metadata) if args.metadata else Metadata()
|
||||||
|
self.metadata_path = None
|
||||||
|
|
||||||
|
|
||||||
class TestResult(object):
|
class TestResult(object):
|
||||||
"""Base class for test results."""
|
"""Base class for test results."""
|
||||||
|
@ -201,9 +248,18 @@ class TestFailure(TestResult):
|
||||||
if messages:
|
if messages:
|
||||||
messages = sorted(messages, key=lambda m: m.sort_key)
|
messages = sorted(messages, key=lambda m: m.sort_key)
|
||||||
|
|
||||||
self.messages = messages
|
self.messages = messages or []
|
||||||
self.summary = summary
|
self.summary = summary
|
||||||
|
|
||||||
|
def write(self, args):
|
||||||
|
"""
|
||||||
|
:type args: TestConfig
|
||||||
|
"""
|
||||||
|
if args.metadata.changes:
|
||||||
|
self.populate_confidence(args.metadata)
|
||||||
|
|
||||||
|
super(TestFailure, self).write(args)
|
||||||
|
|
||||||
def write_console(self):
|
def write_console(self):
|
||||||
"""Write results to console."""
|
"""Write results to console."""
|
||||||
if self.summary:
|
if self.summary:
|
||||||
|
@ -217,7 +273,7 @@ class TestFailure(TestResult):
|
||||||
display.error('Found %d %s issue(s)%s which need to be resolved:' % (len(self.messages), self.test or self.command, specifier))
|
display.error('Found %d %s issue(s)%s which need to be resolved:' % (len(self.messages), self.test or self.command, specifier))
|
||||||
|
|
||||||
for message in self.messages:
|
for message in self.messages:
|
||||||
display.error(message)
|
display.error(message.format(show_confidence=True))
|
||||||
|
|
||||||
def write_lint(self):
|
def write_lint(self):
|
||||||
"""Write lint results to stdout."""
|
"""Write lint results to stdout."""
|
||||||
|
@ -253,7 +309,13 @@ class TestFailure(TestResult):
|
||||||
message = self.format_title()
|
message = self.format_title()
|
||||||
output = self.format_block()
|
output = self.format_block()
|
||||||
|
|
||||||
|
if self.messages:
|
||||||
|
verified = all((m.confidence or 0) >= 50 for m in self.messages)
|
||||||
|
else:
|
||||||
|
verified = False
|
||||||
|
|
||||||
bot_data = dict(
|
bot_data = dict(
|
||||||
|
verified=verified,
|
||||||
results=[
|
results=[
|
||||||
dict(
|
dict(
|
||||||
message=message,
|
message=message,
|
||||||
|
@ -271,6 +333,14 @@ class TestFailure(TestResult):
|
||||||
json.dump(bot_data, bot_fd, indent=4, sort_keys=True)
|
json.dump(bot_data, bot_fd, indent=4, sort_keys=True)
|
||||||
bot_fd.write('\n')
|
bot_fd.write('\n')
|
||||||
|
|
||||||
|
def populate_confidence(self, metadata):
|
||||||
|
"""
|
||||||
|
:type metadata: Metadata
|
||||||
|
"""
|
||||||
|
for message in self.messages:
|
||||||
|
if message.confidence is None:
|
||||||
|
message.confidence = calculate_confidence(message.path, message.line, metadata)
|
||||||
|
|
||||||
def format_command(self):
|
def format_command(self):
|
||||||
"""
|
"""
|
||||||
:rtype: str
|
:rtype: str
|
||||||
|
@ -319,7 +389,7 @@ class TestFailure(TestResult):
|
||||||
|
|
||||||
class TestMessage(object):
|
class TestMessage(object):
|
||||||
"""Single test message for one file."""
|
"""Single test message for one file."""
|
||||||
def __init__(self, message, path, line=0, column=0, level='error', code=None):
|
def __init__(self, message, path, line=0, column=0, level='error', code=None, confidence=None):
|
||||||
"""
|
"""
|
||||||
:type message: str
|
:type message: str
|
||||||
:type path: str
|
:type path: str
|
||||||
|
@ -327,6 +397,7 @@ class TestMessage(object):
|
||||||
:type column: int
|
:type column: int
|
||||||
:type level: str
|
:type level: str
|
||||||
:type code: str | None
|
:type code: str | None
|
||||||
|
:type confidence: int | None
|
||||||
"""
|
"""
|
||||||
self.path = path
|
self.path = path
|
||||||
self.line = line
|
self.line = line
|
||||||
|
@ -334,13 +405,24 @@ class TestMessage(object):
|
||||||
self.level = level
|
self.level = level
|
||||||
self.code = code
|
self.code = code
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.confidence = confidence
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
return self.format()
|
||||||
|
|
||||||
|
def format(self, show_confidence=False):
|
||||||
|
"""
|
||||||
|
:type show_confidence: bool
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
if self.code:
|
if self.code:
|
||||||
msg = '%s %s' % (self.code, self.message)
|
msg = '%s %s' % (self.code, self.message)
|
||||||
else:
|
else:
|
||||||
msg = self.message
|
msg = self.message
|
||||||
|
|
||||||
|
if show_confidence and self.confidence is not None:
|
||||||
|
msg += ' (%d%%)' % self.confidence
|
||||||
|
|
||||||
return '%s:%s:%s: %s' % (self.path, self.line, self.column, msg)
|
return '%s:%s:%s: %s' % (self.path, self.line, self.column, msg)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -165,10 +165,14 @@ def raw_command(cmd, capture=False, env=None, data=None, cwd=None, explain=False
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if communicate:
|
if communicate:
|
||||||
stdout, stderr = process.communicate(data)
|
encoding = 'utf-8'
|
||||||
|
data_bytes = data.encode(encoding) if data else None
|
||||||
|
stdout_bytes, stderr_bytes = process.communicate(data_bytes)
|
||||||
|
stdout_text = stdout_bytes.decode(encoding) if stdout_bytes else u''
|
||||||
|
stderr_text = stderr_bytes.decode(encoding) if stderr_bytes else u''
|
||||||
else:
|
else:
|
||||||
process.wait()
|
process.wait()
|
||||||
stdout, stderr = None, None
|
stdout_text, stderr_text = None, None
|
||||||
|
|
||||||
status = process.returncode
|
status = process.returncode
|
||||||
runtime = time.time() - start
|
runtime = time.time() - start
|
||||||
|
@ -176,9 +180,9 @@ def raw_command(cmd, capture=False, env=None, data=None, cwd=None, explain=False
|
||||||
display.info('Command exited with status %s after %s seconds.' % (status, runtime), verbosity=4)
|
display.info('Command exited with status %s after %s seconds.' % (status, runtime), verbosity=4)
|
||||||
|
|
||||||
if status == 0:
|
if status == 0:
|
||||||
return stdout, stderr
|
return stdout_text, stderr_text
|
||||||
|
|
||||||
raise SubprocessError(cmd, status, stdout, stderr, runtime)
|
raise SubprocessError(cmd, status, stdout_text, stderr_text, runtime)
|
||||||
|
|
||||||
|
|
||||||
def common_environment():
|
def common_environment():
|
||||||
|
@ -266,6 +270,15 @@ def make_dirs(path):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary_file(path):
|
||||||
|
"""
|
||||||
|
:type path: str
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
with open(path, 'rb') as path_fd:
|
||||||
|
return b'\0' in path_fd.read(1024)
|
||||||
|
|
||||||
|
|
||||||
class Display(object):
|
class Display(object):
|
||||||
"""Manages color console output."""
|
"""Manages color console output."""
|
||||||
clear = '\033[0m'
|
clear = '\033[0m'
|
||||||
|
|
|
@ -2,5 +2,6 @@ jinja2
|
||||||
mock
|
mock
|
||||||
pep8
|
pep8
|
||||||
pylint
|
pylint
|
||||||
|
pytest
|
||||||
voluptuous
|
voluptuous
|
||||||
yamllint
|
yamllint
|
||||||
|
|
|
@ -163,6 +163,9 @@ def parse_args():
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='analyze code coverage when running tests')
|
help='analyze code coverage when running tests')
|
||||||
|
|
||||||
|
test.add_argument('--metadata',
|
||||||
|
help=argparse.SUPPRESS)
|
||||||
|
|
||||||
add_changes(test, argparse)
|
add_changes(test, argparse)
|
||||||
add_environments(test)
|
add_environments(test)
|
||||||
|
|
||||||
|
|
97
test/runner/units/test_diff.py
Normal file
97
test/runner/units/test_diff.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
"""Tests for diff module."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lib.diff import (
|
||||||
|
parse_diff,
|
||||||
|
FileDiff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_diff(base, head=None):
|
||||||
|
"""Return a git diff between the base and head revision.
|
||||||
|
:type base: str
|
||||||
|
:type head: str | None
|
||||||
|
:rtype: list[str]
|
||||||
|
"""
|
||||||
|
if not head or head == 'HEAD':
|
||||||
|
head = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip()
|
||||||
|
|
||||||
|
cache = '/tmp/git-diff-cache-%s-%s.log' % (base, head)
|
||||||
|
|
||||||
|
if os.path.exists(cache):
|
||||||
|
with open(cache, 'r') as cache_fd:
|
||||||
|
lines = cache_fd.read().splitlines()
|
||||||
|
else:
|
||||||
|
lines = subprocess.check_output(['git', 'diff', base, head]).splitlines()
|
||||||
|
|
||||||
|
with open(cache, 'w') as cache_fd:
|
||||||
|
cache_fd.write('\n'.join(lines))
|
||||||
|
|
||||||
|
assert lines
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def get_parsed_diff(base, head=None):
|
||||||
|
"""Return a parsed git diff between the base and head revision.
|
||||||
|
:type base: str
|
||||||
|
:type head: str | None
|
||||||
|
:rtype: list[FileDiff]
|
||||||
|
"""
|
||||||
|
lines = get_diff(base, head)
|
||||||
|
items = parse_diff(lines)
|
||||||
|
|
||||||
|
assert items
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
assert item.headers
|
||||||
|
assert item.is_complete
|
||||||
|
|
||||||
|
item.old.format_lines()
|
||||||
|
item.new.format_lines()
|
||||||
|
|
||||||
|
for line_range in item.old.ranges:
|
||||||
|
assert line_range[1] >= line_range[0] > 0
|
||||||
|
|
||||||
|
for line_range in item.new.ranges:
|
||||||
|
assert line_range[1] >= line_range[0] > 0
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
RANGES_TO_TEST = (
|
||||||
|
('f31421576b00f0b167cdbe61217c31c21a41ac02', 'HEAD'),
|
||||||
|
('b8125ac1a61f2c7d1de821c78c884560071895f1', '32146acf4e43e6f95f54d9179bf01f0df9814217')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("base, head", RANGES_TO_TEST)
|
||||||
|
def test_parse_diff(base, head):
|
||||||
|
"""Integration test to verify parsing of ansible/ansible history."""
|
||||||
|
get_parsed_diff(base, head)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delete():
|
||||||
|
"""Integration test to verify parsing of a deleted file."""
|
||||||
|
commit = 'ee17b914554861470b382e9e80a8e934063e0860'
|
||||||
|
items = get_parsed_diff(commit + '~', commit)
|
||||||
|
deletes = [item for item in items if not item.new.exists]
|
||||||
|
|
||||||
|
assert len(deletes) == 1
|
||||||
|
assert deletes[0].old.path == 'lib/ansible/plugins/connection/nspawn.py'
|
||||||
|
assert deletes[0].new.path == 'lib/ansible/plugins/connection/nspawn.py'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rename():
|
||||||
|
"""Integration test to verify parsing of renamed files."""
|
||||||
|
commit = '16a39639f568f4dd5cb233df2d0631bdab3a05e9'
|
||||||
|
items = get_parsed_diff(commit + '~', commit)
|
||||||
|
renames = [item for item in items if item.old.path != item.new.path and item.old.exists and item.new.exists]
|
||||||
|
|
||||||
|
assert len(renames) == 2
|
||||||
|
assert renames[0].old.path == 'test/integration/targets/eos_eapi/tests/cli/badtransport.yaml'
|
||||||
|
assert renames[0].new.path == 'test/integration/targets/eos_eapi/tests/cli/badtransport.1'
|
||||||
|
assert renames[1].old.path == 'test/integration/targets/eos_eapi/tests/cli/zzz_reset.yaml'
|
||||||
|
assert renames[1].new.path == 'test/integration/targets/eos_eapi/tests/cli/zzz_reset.1'
|
|
@ -17,10 +17,14 @@ ln -sf x86_64-linux-gnu-gcc-4.9 /usr/bin/x86_64-linux-gnu-gcc
|
||||||
|
|
||||||
retry.py pip install tox --disable-pip-version-check
|
retry.py pip install tox --disable-pip-version-check
|
||||||
|
|
||||||
|
echo '{"verified": false, "results": []}' > test/results/bot/ansible-test-failure.json
|
||||||
|
|
||||||
ansible-test compile --failure-ok --color -v --junit --requirements
|
ansible-test compile --failure-ok --color -v --junit --requirements
|
||||||
ansible-test sanity --failure-ok --color -v --junit --tox --skip-test ansible-doc --python 2.7
|
ansible-test sanity --failure-ok --color -v --junit --tox --skip-test ansible-doc --python 2.7
|
||||||
ansible-test sanity --failure-ok --color -v --junit --tox --test ansible-doc --coverage
|
ansible-test sanity --failure-ok --color -v --junit --tox --test ansible-doc --coverage
|
||||||
|
|
||||||
|
rm test/results/bot/ansible-test-failure.json
|
||||||
|
|
||||||
if find test/results/bot/ -mindepth 1 -name '.*' -prune -o -print -quit | grep -q .; then
|
if find test/results/bot/ -mindepth 1 -name '.*' -prune -o -print -quit | grep -q .; then
|
||||||
echo "One or more of the above ansible-test commands recorded at least one test failure."
|
echo "One or more of the above ansible-test commands recorded at least one test failure."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
Loading…
Reference in a new issue