blob: 53747b27b926f4f46b7a20037d9dded52e313466 [file] [log] [blame]
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
"""A test base class for tests based on gold file comparison."""
from __future__ import annotations
import difflib
import filecmp
import fnmatch
import os
import os.path
import re
import xml.etree.ElementTree
from collections.abc import Iterable
from tests.coveragetest import TESTS_DIR
from tests.helpers import os_sep
def gold_path(path: str) -> str:
"""Get a path to a gold file for comparison."""
return os.path.join(TESTS_DIR, "gold", path)
def compare(
expected_dir: str,
actual_dir: str,
file_pattern: str | None = None,
actual_extra: bool = False,
scrubs: list[tuple[str, str]] | None = None,
) -> None:
"""Compare files matching `file_pattern` in `expected_dir` and `actual_dir`.
`actual_extra` true means `actual_dir` can have extra files in it
without triggering an assertion.
`scrubs` is a list of pairs: regexes to find and replace to scrub the
files of unimportant differences.
If a comparison fails, a message will be written to stdout, the original
unscrubbed output of the test will be written to an "/actual/" directory
alongside the "/gold/" directory, and an assertion will be raised.
"""
__tracebackhide__ = True # pytest, please don't show me this function.
assert os_sep("/gold/") in expected_dir
assert os.path.exists(actual_dir)
os.makedirs(expected_dir, exist_ok=True)
dc = filecmp.dircmp(expected_dir, actual_dir)
diff_files = _fnmatch_list(dc.diff_files, file_pattern)
expected_only = _fnmatch_list(dc.left_only, file_pattern)
actual_only = _fnmatch_list(dc.right_only, file_pattern)
def save_mismatch(f: str) -> None:
"""Save a mismatched result to tests/actual."""
save_path = expected_dir.replace(os_sep("/gold/"), os_sep("/actual/"))
os.makedirs(save_path, exist_ok=True)
save_file = os.path.join(save_path, f)
with open(save_file, "w", encoding="utf-8") as savef:
with open(os.path.join(actual_dir, f), encoding="utf-8") as readf:
savef.write(readf.read())
print(os_sep(f"Saved actual output to '{save_file}': see tests/gold/README.rst"))
# filecmp only compares in binary mode, but we want text mode. So
# look through the list of different files, and compare them
# ourselves.
text_diff = []
for f in diff_files:
expected_file = os.path.join(expected_dir, f)
with open(expected_file, encoding="utf-8") as fobj:
expected = fobj.read()
if expected_file.endswith(".xml"):
expected = canonicalize_xml(expected)
actual_file = os.path.join(actual_dir, f)
with open(actual_file, encoding="utf-8") as fobj:
actual = fobj.read()
if actual_file.endswith(".xml"):
actual = canonicalize_xml(actual)
if scrubs:
expected = scrub(expected, scrubs)
actual = scrub(actual, scrubs)
if expected != actual:
text_diff.append(f"{expected_file} != {actual_file}")
expected_lines = expected.splitlines()
actual_lines = actual.splitlines()
print(f":::: diff '{expected_file}' and '{actual_file}'")
print("\n".join(difflib.Differ().compare(expected_lines, actual_lines)))
print(f":::: end diff '{expected_file}' and '{actual_file}'")
print(f"==== expected output in '{os.path.abspath(expected_dir)}'")
print(f"==== actual output in '{os.path.abspath(actual_dir)}'")
save_mismatch(f)
if not actual_extra:
for f in actual_only:
save_mismatch(f)
assert not text_diff, "Files differ: " + "\n".join(text_diff)
assert not expected_only, f"Files in {os.path.abspath(expected_dir)} only: {expected_only}"
if not actual_extra:
assert not actual_only, f"Files in {os.path.abspath(actual_dir)} only: {actual_only}"
def contains(filename: str, *strlist: str) -> None:
"""Check that the file contains all of a list of strings.
An assert will be raised if one of the arguments in `strlist` is
missing in `filename`.
"""
__tracebackhide__ = True # pytest, please don't show me this function.
with open(filename, encoding="utf-8") as fobj:
text = fobj.read()
for s in strlist:
assert s in text, f"Missing content in {filename}: {s!r}"
def contains_rx(filename: str, *rxlist: str) -> None:
"""Check that the file has lines that re.search all of the regexes.
An assert will be raised if one of the regexes in `rxlist` doesn't match
any lines in `filename`.
"""
__tracebackhide__ = True # pytest, please don't show me this function.
with open(filename, encoding="utf-8") as fobj:
lines = fobj.readlines()
for rx in rxlist:
assert any(re.search(rx, line) for line in lines), f"Missing regex in {filename}: r{rx!r}"
def contains_any(filename: str, *strlist: str) -> None:
"""Check that the file contains at least one of a list of strings.
An assert will be raised if none of the arguments in `strlist` is in
`filename`.
"""
__tracebackhide__ = True # pytest, please don't show me this function.
with open(filename, encoding="utf-8") as fobj:
text = fobj.read()
for s in strlist:
if s in text:
return
assert False, f"Missing content in {filename}: {strlist[0]!r} [1 of {len(strlist)}]"
def doesnt_contain(filename: str, *strlist: str) -> None:
"""Check that the file contains none of a list of strings.
An assert will be raised if any of the strings in `strlist` appears in
`filename`.
"""
__tracebackhide__ = True # pytest, please don't show me this function.
with open(filename, encoding="utf-8") as fobj:
text = fobj.read()
for s in strlist:
assert s not in text, f"Forbidden content in {filename}: {s!r}"
# Helpers
def canonicalize_xml(xtext: str) -> str:
"""Canonicalize some XML text."""
root = xml.etree.ElementTree.fromstring(xtext)
for node in root.iter():
node.attrib = dict(sorted(node.items()))
return xml.etree.ElementTree.tostring(root).decode("utf-8")
def _fnmatch_list(files: list[str], file_pattern: str | None) -> list[str]:
"""Filter the list of `files` to only those that match `file_pattern`.
If `file_pattern` is None, then return the entire list of files.
Returns a list of the filtered files.
"""
if file_pattern:
files = [f for f in files if fnmatch.fnmatch(f, file_pattern)]
return files
def scrub(strdata: str, scrubs: Iterable[tuple[str, str]]) -> str:
"""Scrub uninteresting data from the payload in `strdata`.
`scrubs` is a list of (find, replace) pairs of regexes that are used on
`strdata`. A string is returned.
"""
for rx_find, rx_replace in scrubs:
strdata = re.sub(rx_find, rx_replace, strdata)
return strdata