mirror of https://github.com/ansible/ansible.git
Add test verification to ansible-test. (#22636)
* Add unified git diff parser. * Add metadata and diff handling. * Add test confidence/verification to bot output.pull/22384/head
parent
5e9a2b8528
commit
869449e288
@ -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}
|
@ -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)
|
@ -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
|
@ -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'
|
Loading…
Reference in New Issue