diff --git a/.coveragerc b/.coveragerc index faa494f8e6..ddfcbd3348 100644 --- a/.coveragerc +++ b/.coveragerc @@ -24,5 +24,5 @@ exclude_lines = if TYPE_CHECKING: @overload - # Abstract methods are not exectued during pytest runs + # Abstract methods are not executed during pytest runs raise NotImplementedError() diff --git a/.pyenchant_pylint_custom_dict.txt b/.pyenchant_pylint_custom_dict.txt index 80918ac5aa..825ffe1f8c 100644 --- a/.pyenchant_pylint_custom_dict.txt +++ b/.pyenchant_pylint_custom_dict.txt @@ -154,6 +154,7 @@ iterables iteritems jn jpg +json jx jython # class is a reserved word diff --git a/pylint/testutils/_primer/primer.py b/pylint/testutils/_primer/primer.py index 48b3433f20..3a911df4bb 100644 --- a/pylint/testutils/_primer/primer.py +++ b/pylint/testutils/_primer/primer.py @@ -6,25 +6,13 @@ import argparse import json -import sys -import warnings -from io import StringIO -from itertools import chain from pathlib import Path -from typing import Dict, List, Union -import git - -from pylint.lint import Run -from pylint.reporters import JSONReporter from pylint.testutils._primer import PackageToLint - -MAX_GITHUB_COMMENT_LENGTH = 65536 - -PackageMessages = Dict[str, List[Dict[str, Union[str, int]]]] - -GITHUB_CRASH_TEMPLATE_LOCATION = "/home/runner/.cache" -CRASH_TEMPLATE_INTRO = "There is a pre-filled template" +from pylint.testutils._primer.primer_command import PrimerCommand +from pylint.testutils._primer.primer_compare_command import CompareCommand +from pylint.testutils._primer.primer_prepare_command import PrepareCommand +from pylint.testutils._primer.primer_run_command import RunCommand class Primer: @@ -90,212 +78,16 @@ def __init__(self, primer_directory: Path, json_path: Path) -> None: self.packages = self._get_packages_to_lint_from_json(json_path) """All packages to prime.""" - def run(self) -> None: if self.config.command == "prepare": - self._handle_prepare_command() + command_class: type[PrimerCommand] = PrepareCommand if self.config.command == "run": - self._handle_run_command() + command_class = RunCommand if self.config.command == "compare": - self._handle_compare_command() - - def _handle_prepare_command(self) -> None: - commit_string = "" - if self.config.clone: - for package, data in self.packages.items(): - local_commit = data.lazy_clone() - print(f"Cloned '{package}' at commit '{local_commit}'.") - commit_string += local_commit + "_" - elif self.config.check: - for package, data in self.packages.items(): - local_commit = git.Repo(data.clone_directory).head.object.hexsha - print(f"Found '{package}' at commit '{local_commit}'.") - commit_string += local_commit + "_" - elif self.config.make_commit_string: - for package, data in self.packages.items(): - remote_sha1_commit = ( - git.cmd.Git().ls_remote(data.url, data.branch).split("\t")[0] - ) - print(f"'{package}' remote is at commit '{remote_sha1_commit}'.") - commit_string += remote_sha1_commit + "_" - elif self.config.read_commit_string: - with open( - self.primer_directory / "commit_string.txt", encoding="utf-8" - ) as f: - print(f.read()) - - if commit_string: - with open( - self.primer_directory / "commit_string.txt", "w", encoding="utf-8" - ) as f: - f.write(commit_string) - - def _handle_run_command(self) -> None: - packages: PackageMessages = {} - - for package, data in self.packages.items(): - output = self._lint_package(data) - packages[package] = output - print(f"Successfully primed {package}.") - - astroid_errors = [] - other_fatal_msgs = [] - for msg in chain.from_iterable(packages.values()): - if msg["type"] == "fatal": - # Remove the crash template location if we're running on GitHub. - # We were falsely getting "new" errors when the timestamp changed. - assert isinstance(msg["message"], str) - if GITHUB_CRASH_TEMPLATE_LOCATION in msg["message"]: - msg["message"] = msg["message"].rsplit(CRASH_TEMPLATE_INTRO)[0] - if msg["symbol"] == "astroid-error": - astroid_errors.append(msg) - else: - other_fatal_msgs.append(msg) - - with open( - self.primer_directory - / f"output_{'.'.join(str(i) for i in sys.version_info[:3])}_{self.config.type}.txt", - "w", - encoding="utf-8", - ) as f: - json.dump(packages, f) - - # Fail loudly (and fail CI pipelines) if any fatal errors are found, - # unless they are astroid-errors, in which case just warn. - # This is to avoid introducing a dependency on bleeding-edge astroid - # for pylint CI pipelines generally, even though we want to use astroid main - # for the purpose of diffing emitted messages and generating PR comments. - if astroid_errors: - warnings.warn(f"Fatal errors traced to astroid: {astroid_errors}") - assert not other_fatal_msgs, other_fatal_msgs - - def _handle_compare_command(self) -> None: - with open(self.config.base_file, encoding="utf-8") as f: - main_dict: PackageMessages = json.load(f) - with open(self.config.new_file, encoding="utf-8") as f: - new_dict: PackageMessages = json.load(f) + command_class = CompareCommand + self.command = command_class(self.primer_directory, self.packages, self.config) - final_main_dict: PackageMessages = {} - for package, messages in main_dict.items(): - final_main_dict[package] = [] - for message in messages: - try: - new_dict[package].remove(message) - except ValueError: - final_main_dict[package].append(message) - - self._create_comment(final_main_dict, new_dict) - - def _create_comment( - self, all_missing_messages: PackageMessages, all_new_messages: PackageMessages - ) -> None: - comment = "" - for package, missing_messages in all_missing_messages.items(): - if len(comment) >= MAX_GITHUB_COMMENT_LENGTH: - break - - new_messages = all_new_messages[package] - package_data = self.packages[package] - - if not missing_messages and not new_messages: - continue - - comment += f"\n\n**Effect on [{package}]({self.packages[package].url}):**\n" - - # Create comment for new messages - count = 1 - astroid_errors = 0 - new_non_astroid_messages = "" - if new_messages: - print("Now emitted:") - for message in new_messages: - filepath = str(message["path"]).replace( - str(package_data.clone_directory), "" - ) - # Existing astroid errors may still show up as "new" because the timestamp - # in the message is slightly different. - if message["symbol"] == "astroid-error": - astroid_errors += 1 - else: - new_non_astroid_messages += ( - f"{count}) {message['symbol']}:\n*{message['message']}*\n" - f"{package_data.url}/blob/{package_data.branch}{filepath}#L{message['line']}\n" - ) - print(message) - count += 1 - - if astroid_errors: - comment += ( - f"{astroid_errors} error(s) were found stemming from the `astroid` library. " - "This is unlikely to have been caused by your changes. " - "A GitHub Actions warning links directly to the crash report template. " - "Please open an issue against `astroid` if one does not exist already. \n\n" - ) - if new_non_astroid_messages: - comment += ( - "The following messages are now emitted:\n\n
\n\n" - + new_non_astroid_messages - + "\n
\n\n" - ) - - # Create comment for missing messages - count = 1 - if missing_messages: - comment += ( - "The following messages are no longer emitted:\n\n
\n\n" - ) - print("No longer emitted:") - for message in missing_messages: - comment += f"{count}) {message['symbol']}:\n*{message['message']}*\n" - filepath = str(message["path"]).replace( - str(package_data.clone_directory), "" - ) - assert not package_data.url.endswith( - ".git" - ), "You don't need the .git at the end of the github url." - comment += f"{package_data.url}/blob/{package_data.branch}{filepath}#L{message['line']}\n" - count += 1 - print(message) - if missing_messages: - comment += "\n
\n\n" - - if comment == "": - comment = ( - "🤖 According to the primer, this change has **no effect** on the" - " checked open source code. 🤖🎉\n\n" - ) - else: - comment = ( - f"🤖 **Effect of this PR on checked open source code:** 🤖\n\n{comment}" - ) - hash_information = ( - f"*This comment was generated for commit {self.config.commit}*" - ) - if len(comment) + len(hash_information) >= MAX_GITHUB_COMMENT_LENGTH: - truncation_information = ( - f"*This comment was truncated because GitHub allows only" - f" {MAX_GITHUB_COMMENT_LENGTH} characters in a comment.*" - ) - max_len = ( - MAX_GITHUB_COMMENT_LENGTH - - len(hash_information) - - len(truncation_information) - ) - comment = f"{comment[:max_len - 10]}...\n\n{truncation_information}\n\n" - comment += hash_information - with open(self.primer_directory / "comment.txt", "w", encoding="utf-8") as f: - f.write(comment) - - def _lint_package(self, data: PackageToLint) -> list[dict[str, str | int]]: - # We want to test all the code we can - enables = ["--enable-all-extensions", "--enable=all"] - # Duplicate code takes too long and is relatively safe - # TODO: Find a way to allow cyclic-import and compare output correctly - disables = ["--disable=duplicate-code,cyclic-import"] - arguments = data.pylint_args + enables + disables - output = StringIO() - reporter = JSONReporter(output) - Run(arguments, reporter=reporter, exit=False) - return json.loads(output.getvalue()) + def run(self) -> None: + self.command.run() @staticmethod def _get_packages_to_lint_from_json(json_path: Path) -> dict[str, PackageToLint]: diff --git a/pylint/testutils/_primer/primer_command.py b/pylint/testutils/_primer/primer_command.py new file mode 100644 index 0000000000..b3a39445f9 --- /dev/null +++ b/pylint/testutils/_primer/primer_command.py @@ -0,0 +1,32 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +from __future__ import annotations + +import abc +import argparse +from pathlib import Path +from typing import Dict, List, Union + +from pylint.testutils._primer import PackageToLint + +PackageMessages = Dict[str, List[Dict[str, Union[str, int]]]] + + +class PrimerCommand: + """Generic primer action with required arguments.""" + + def __init__( + self, + primer_directory: Path, + packages: dict[str, PackageToLint], + config: argparse.Namespace, + ) -> None: + self.primer_directory = primer_directory + self.packages = packages + self.config = config + + @abc.abstractmethod + def run(self) -> None: + pass diff --git a/pylint/testutils/_primer/primer_compare_command.py b/pylint/testutils/_primer/primer_compare_command.py new file mode 100644 index 0000000000..48953b7f1f --- /dev/null +++ b/pylint/testutils/_primer/primer_compare_command.py @@ -0,0 +1,146 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt +from __future__ import annotations + +import json +from pathlib import Path + +from pylint.testutils._primer.primer_command import PackageMessages, PrimerCommand + +MAX_GITHUB_COMMENT_LENGTH = 65536 + + +class CompareCommand(PrimerCommand): + def run(self) -> None: + main_messages = self._load_json(self.config.base_file) + pr_messages = self._load_json(self.config.new_file) + missing_messages, new_messages = self._cross_reference( + main_messages, pr_messages + ) + comment = self._create_comment(missing_messages, new_messages) + with open(self.primer_directory / "comment.txt", "w", encoding="utf-8") as f: + f.write(comment) + + @staticmethod + def _cross_reference( + main_dict: PackageMessages, pr_messages: PackageMessages + ) -> tuple[PackageMessages, PackageMessages]: + missing_messages: PackageMessages = {} + for package, messages in main_dict.items(): + missing_messages[package] = [] + for message in messages: + try: + pr_messages[package].remove(message) + except ValueError: + missing_messages[package].append(message) + return missing_messages, pr_messages + + @staticmethod + def _load_json(file_path: Path | str) -> PackageMessages: + with open(file_path, encoding="utf-8") as f: + result: PackageMessages = json.load(f) + return result + + def _create_comment( + self, all_missing_messages: PackageMessages, all_new_messages: PackageMessages + ) -> str: + comment = "" + for package, missing_messages in all_missing_messages.items(): + if len(comment) >= MAX_GITHUB_COMMENT_LENGTH: + break + new_messages = all_new_messages[package] + if not missing_messages and not new_messages: + continue + comment += self._create_comment_for_package( + package, new_messages, missing_messages + ) + if comment == "": + comment = ( + "🤖 According to the primer, this change has **no effect** on the" + " checked open source code. 🤖🎉\n\n" + ) + else: + comment = ( + f"🤖 **Effect of this PR on checked open source code:** 🤖\n\n{comment}" + ) + return self._truncate_comment(comment) + + def _create_comment_for_package( + self, package: str, new_messages, missing_messages + ) -> str: + comment = f"\n\n**Effect on [{package}]({self.packages[package].url}):**\n" + # Create comment for new messages + count = 1 + astroid_errors = 0 + new_non_astroid_messages = "" + if new_messages: + print("Now emitted:") + for message in new_messages: + filepath = str(message["path"]).replace( + str(self.packages[package].clone_directory), "" + ) + # Existing astroid errors may still show up as "new" because the timestamp + # in the message is slightly different. + if message["symbol"] == "astroid-error": + astroid_errors += 1 + else: + new_non_astroid_messages += ( + f"{count}) {message['symbol']}:\n*{message['message']}*\n" + f"{self.packages[package].url}/blob/{self.packages[package].branch}{filepath}#L{message['line']}\n" + ) + print(message) + count += 1 + + if astroid_errors: + comment += ( + f"{astroid_errors} error(s) were found stemming from the `astroid` library. " + "This is unlikely to have been caused by your changes. " + "A GitHub Actions warning links directly to the crash report template. " + "Please open an issue against `astroid` if one does not exist already. \n\n" + ) + if new_non_astroid_messages: + comment += ( + "The following messages are now emitted:\n\n
\n\n" + + new_non_astroid_messages + + "\n
\n\n" + ) + + # Create comment for missing messages + count = 1 + if missing_messages: + comment += "The following messages are no longer emitted:\n\n
\n\n" + print("No longer emitted:") + for message in missing_messages: + comment += f"{count}) {message['symbol']}:\n*{message['message']}*\n" + filepath = str(message["path"]).replace( + str(self.packages[package].clone_directory), "" + ) + assert not self.packages[package].url.endswith( + ".git" + ), "You don't need the .git at the end of the github url." + comment += f"{self.packages[package].url}/blob/{self.packages[package].branch}{filepath}#L{message['line']}\n" + count += 1 + print(message) + if missing_messages: + comment += "\n
\n\n" + return comment + + def _truncate_comment(self, comment: str) -> str: + """GitHub allows only a set number of characters in a comment.""" + hash_information = ( + f"*This comment was generated for commit {self.config.commit}*" + ) + if len(comment) + len(hash_information) >= MAX_GITHUB_COMMENT_LENGTH: + truncation_information = ( + f"*This comment was truncated because GitHub allows only" + f" {MAX_GITHUB_COMMENT_LENGTH} characters in a comment.*" + ) + max_len = ( + MAX_GITHUB_COMMENT_LENGTH + - len(hash_information) + - len(truncation_information) + ) + comment = f"{comment[:max_len - 10]}...\n\n{truncation_information}\n\n" + comment += hash_information + return comment diff --git a/pylint/testutils/_primer/primer_prepare_command.py b/pylint/testutils/_primer/primer_prepare_command.py new file mode 100644 index 0000000000..9fec829bc6 --- /dev/null +++ b/pylint/testutils/_primer/primer_prepare_command.py @@ -0,0 +1,41 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt +from __future__ import annotations + +import git + +from pylint.testutils._primer.primer_command import PrimerCommand + + +class PrepareCommand(PrimerCommand): + def run(self) -> None: + commit_string = "" + if self.config.clone: + for package, data in self.packages.items(): + local_commit = data.lazy_clone() + print(f"Cloned '{package}' at commit '{local_commit}'.") + commit_string += local_commit + "_" + elif self.config.check: + for package, data in self.packages.items(): + local_commit = git.Repo(data.clone_directory).head.object.hexsha + print(f"Found '{package}' at commit '{local_commit}'.") + commit_string += local_commit + "_" + elif self.config.make_commit_string: + for package, data in self.packages.items(): + remote_sha1_commit = ( + git.cmd.Git().ls_remote(data.url, data.branch).split("\t")[0] + ) + print(f"'{package}' remote is at commit '{remote_sha1_commit}'.") + commit_string += remote_sha1_commit + "_" + elif self.config.read_commit_string: + with open( + self.primer_directory / "commit_string.txt", encoding="utf-8" + ) as f: + print(f.read()) + + if commit_string: + with open( + self.primer_directory / "commit_string.txt", "w", encoding="utf-8" + ) as f: + f.write(commit_string) diff --git a/pylint/testutils/_primer/primer_run_command.py b/pylint/testutils/_primer/primer_run_command.py new file mode 100644 index 0000000000..aba2ed115e --- /dev/null +++ b/pylint/testutils/_primer/primer_run_command.py @@ -0,0 +1,72 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +from __future__ import annotations + +import json +import sys +import warnings +from io import StringIO +from itertools import chain + +from pylint.lint import Run +from pylint.reporters import JSONReporter +from pylint.testutils._primer.package_to_lint import PackageToLint +from pylint.testutils._primer.primer_command import PackageMessages, PrimerCommand + +GITHUB_CRASH_TEMPLATE_LOCATION = "/home/runner/.cache" +CRASH_TEMPLATE_INTRO = "There is a pre-filled template" + + +class RunCommand(PrimerCommand): + def run(self) -> None: + packages: PackageMessages = {} + + for package, data in self.packages.items(): + output = self._lint_package(data) + packages[package] = output + print(f"Successfully primed {package}.") + + astroid_errors = [] + other_fatal_msgs = [] + for msg in chain.from_iterable(packages.values()): + if msg["type"] == "fatal": + # Remove the crash template location if we're running on GitHub. + # We were falsely getting "new" errors when the timestamp changed. + assert isinstance(msg["message"], str) + if GITHUB_CRASH_TEMPLATE_LOCATION in msg["message"]: + msg["message"] = msg["message"].rsplit(CRASH_TEMPLATE_INTRO)[0] + if msg["symbol"] == "astroid-error": + astroid_errors.append(msg) + else: + other_fatal_msgs.append(msg) + + with open( + self.primer_directory + / f"output_{'.'.join(str(i) for i in sys.version_info[:3])}_{self.config.type}.txt", + "w", + encoding="utf-8", + ) as f: + json.dump(packages, f) + + # Fail loudly (and fail CI pipelines) if any fatal errors are found, + # unless they are astroid-errors, in which case just warn. + # This is to avoid introducing a dependency on bleeding-edge astroid + # for pylint CI pipelines generally, even though we want to use astroid main + # for the purpose of diffing emitted messages and generating PR comments. + if astroid_errors: + warnings.warn(f"Fatal errors traced to astroid: {astroid_errors}") + assert not other_fatal_msgs, other_fatal_msgs + + def _lint_package(self, data: PackageToLint) -> list[dict[str, str | int]]: + # We want to test all the code we can + enables = ["--enable-all-extensions", "--enable=all"] + # Duplicate code takes too long and is relatively safe + # TODO: Find a way to allow cyclic-import and compare output correctly + disables = ["--disable=duplicate-code,cyclic-import"] + arguments = data.pylint_args + enables + disables + output = StringIO() + reporter = JSONReporter(output) + Run(arguments, reporter=reporter, exit=False) + return json.loads(output.getvalue()) diff --git a/tests/testutils/_primer/fixtures/message_changed/expected_truncated.txt b/tests/testutils/_primer/fixtures/message_changed/expected_truncated.txt new file mode 100644 index 0000000000..e8db96a3ed --- /dev/null +++ b/tests/testutils/_primer/fixtures/message_changed/expected_truncated.txt @@ -0,0 +1,20 @@ +🤖 **Effect of this PR on checked open source code:** 🤖 + + + +**Effect on [astroid](https://github.com/PyCQA/astroid):** +The following messages are now emitted: + +
+ +1) locally-disabled: +*Locally disabling redefined-builtin [we added some text in the message] (W0622)* +https://github.com/PyCQA/astroid/blob/main/astroid/__init__.py#L91 + +
+ +The fol... + +*This comment was truncated because GitHub allows only 500 characters in a comment.* + +*This comment was generated for commit v2.14.2* diff --git a/tests/testutils/_primer/test_primer.py b/tests/testutils/_primer/test_primer.py index 99ef4bcbdd..3093fa98ee 100644 --- a/tests/testutils/_primer/test_primer.py +++ b/tests/testutils/_primer/test_primer.py @@ -3,6 +3,8 @@ # Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt """Test the primer commands. """ +from __future__ import annotations + import sys from pathlib import Path from unittest.mock import patch @@ -20,39 +22,70 @@ PRIMER_CURRENT_INTERPRETER = (3, 10) +DEFAULT_ARGS = ["python tests/primer/__main__.py", "compare", "--commit=v2.14.2"] + @pytest.mark.skipif( sys.platform in {"win32", "darwin"}, - reason="Primers are internal will never be run on costly github action (mac or windows)", + reason=( + "Primers are internal and will never be run on costly github action (mac or windows)" + ), ) @pytest.mark.skipif( sys.version_info[:2] != PRIMER_CURRENT_INTERPRETER or IS_PYPY, - reason=f"Primers are internal will always be run for only one interpreter (currently {PRIMER_CURRENT_INTERPRETER})", -) -@pytest.mark.parametrize( - "directory", - [ - pytest.param(p, id=str(p.relative_to(FIXTURES_PATH))) - for p in FIXTURES_PATH.iterdir() - if p.is_dir() - ], + reason=( + "Primers are internal and will always be run for only one interpreter (currently" + f" {PRIMER_CURRENT_INTERPRETER})" + ), ) -def test_compare(directory: Path) -> None: - main = directory / "main.json" - pr = directory / "pr.json" - expected_file = directory / "expected.txt" - new_argv = [ - "python tests/primer/__main__.py", - "compare", - "--commit=v2.14.2", - f"--base-file={main}", - f"--new-file={pr}", - ] - with patch("sys.argv", new_argv): - Primer(PRIMER_DIRECTORY, PACKAGES_TO_PRIME_PATH).run() - with open(PRIMER_DIRECTORY / "comment.txt", encoding="utf8") as f: - content = f.read() - with open(expected_file, encoding="utf8") as f: - expected = f.read() - # rstrip so the expected.txt can end with a newline - assert content == expected.rstrip("\n") +class TestPrimer: + @pytest.mark.parametrize( + "directory", + [ + pytest.param(p, id=str(p.relative_to(FIXTURES_PATH))) + for p in FIXTURES_PATH.iterdir() + if p.is_dir() + ], + ) + def test_compare(self, directory: Path) -> None: + """Test for the standard case. + + Directory in 'fixtures/' with 'main.json', 'pr.json' and 'expected.txt'.""" + self.__assert_expected(directory) + + def test_truncated_compare(self) -> None: + """Test for the truncation of comments that are too long.""" + max_comment_length = 500 + directory = FIXTURES_PATH / "message_changed" + with patch( + "pylint.testutils._primer.primer_compare_command.MAX_GITHUB_COMMENT_LENGTH", + max_comment_length, + ): + content = self.__assert_expected( + directory, expected_file=directory / "expected_truncated.txt" + ) + assert len(content) < max_comment_length + + @staticmethod + def __assert_expected( + directory: Path, + main: Path | None = None, + pr: Path | None = None, + expected_file: Path | None = None, + ) -> str: + if main is None: + main = directory / "main.json" + if pr is None: + pr = directory / "pr.json" + if expected_file is None: + expected_file = directory / "expected.txt" + new_argv = DEFAULT_ARGS + [f"--base-file={main}", f"--new-file={pr}"] + with patch("sys.argv", new_argv): + Primer(PRIMER_DIRECTORY, PACKAGES_TO_PRIME_PATH).run() + with open(PRIMER_DIRECTORY / "comment.txt", encoding="utf8") as f: + content = f.read() + with open(expected_file, encoding="utf8") as f: + expected = f.read() + # rstrip so the expected.txt can end with a newline + assert content == expected.rstrip("\n") + return content