From df22b2b770f6b270bc43f3a487f3af043f183d1d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 May 2025 00:30:59 +0200 Subject: [PATCH 1/8] Allow different test output for different Python versions --- pylint/testutils/functional/test_file.py | 20 ++++++++++++++++++- ... => unnecessary_default_type_args.313.txt} | 0 .../typing/unnecessary_default_type_args.py | 4 ++-- .../typing/unnecessary_default_type_args.rc | 4 +++- .../unnecessary_default_type_args_py313.py | 17 ---------------- .../unnecessary_default_type_args_py313.rc | 3 --- 6 files changed, 24 insertions(+), 24 deletions(-) rename tests/functional/ext/typing/{unnecessary_default_type_args_py313.txt => unnecessary_default_type_args.313.txt} (100%) delete mode 100644 tests/functional/ext/typing/unnecessary_default_type_args_py313.py delete mode 100644 tests/functional/ext/typing/unnecessary_default_type_args_py313.rc diff --git a/pylint/testutils/functional/test_file.py b/pylint/testutils/functional/test_file.py index 37ba3a5fc6..402ee065eb 100644 --- a/pylint/testutils/functional/test_file.py +++ b/pylint/testutils/functional/test_file.py @@ -5,6 +5,8 @@ from __future__ import annotations import configparser +import os +import sys from collections.abc import Callable from os.path import basename, exists, join from typing import TypedDict @@ -99,7 +101,23 @@ def module(self) -> str: @property def expected_output(self) -> str: - return self._file_type(".txt", check_exists=False) + files = list( + filter( + lambda s: s.startswith(f"{self.base}.") and s.endswith(".txt"), + os.listdir(self._directory), + ) + ) + # pylint: disable-next=bad-builtin + current_version = int("".join(map(str, sys.version_info[:2]))) + output_options = [ + int(version) + for s in files + if s.count(".") == 2 and (version := s.rsplit(".", maxsplit=2)[1]).isalnum() + ] + for opt in sorted(output_options, reverse=True): + if current_version >= opt: + return join(self._directory, f"{self.base}.{opt}.txt") + return join(self._directory, self.base + ".txt") @property def source(self) -> str: diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.txt b/tests/functional/ext/typing/unnecessary_default_type_args.313.txt similarity index 100% rename from tests/functional/ext/typing/unnecessary_default_type_args_py313.txt rename to tests/functional/ext/typing/unnecessary_default_type_args.313.txt diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.py b/tests/functional/ext/typing/unnecessary_default_type_args.py index e2d1d700de..7b43e23344 100644 --- a/tests/functional/ext/typing/unnecessary_default_type_args.py +++ b/tests/functional/ext/typing/unnecessary_default_type_args.py @@ -3,10 +3,10 @@ import typing as t a1: t.Generator[int, str, str] -a2: t.Generator[int, None, None] +a2: t.Generator[int, None, None] # >=3.13:[unnecessary-default-type-args] a3: t.Generator[int] b1: t.AsyncGenerator[int, str] -b2: t.AsyncGenerator[int, None] +b2: t.AsyncGenerator[int, None] # >=3.13:[unnecessary-default-type-args] b3: t.AsyncGenerator[int] c1: ca.Generator[int, str, str] diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.rc b/tests/functional/ext/typing/unnecessary_default_type_args.rc index 63e11a4e6b..910f36995a 100644 --- a/tests/functional/ext/typing/unnecessary_default_type_args.rc +++ b/tests/functional/ext/typing/unnecessary_default_type_args.rc @@ -1,3 +1,5 @@ [main] -py-version=3.10 load-plugins=pylint.extensions.typing + +[testoptions] +min_pyver=3.10 diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.py b/tests/functional/ext/typing/unnecessary_default_type_args_py313.py deleted file mode 100644 index 9dec4c4075..0000000000 --- a/tests/functional/ext/typing/unnecessary_default_type_args_py313.py +++ /dev/null @@ -1,17 +0,0 @@ -# pylint: disable=missing-docstring,deprecated-typing-alias -import collections.abc as ca -import typing as t - -a1: t.Generator[int, str, str] -a2: t.Generator[int, None, None] # [unnecessary-default-type-args] -a3: t.Generator[int] -b1: t.AsyncGenerator[int, str] -b2: t.AsyncGenerator[int, None] # [unnecessary-default-type-args] -b3: t.AsyncGenerator[int] - -c1: ca.Generator[int, str, str] -c2: ca.Generator[int, None, None] # [unnecessary-default-type-args] -c3: ca.Generator[int] -d1: ca.AsyncGenerator[int, str] -d2: ca.AsyncGenerator[int, None] # [unnecessary-default-type-args] -d3: ca.AsyncGenerator[int] diff --git a/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc b/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc deleted file mode 100644 index d2db5fe7ca..0000000000 --- a/tests/functional/ext/typing/unnecessary_default_type_args_py313.rc +++ /dev/null @@ -1,3 +0,0 @@ -[main] -py-version=3.13 -load-plugins=pylint.extensions.typing From d5019c15c65019fdebc6379d2c5b3fae9b10c629 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 May 2025 12:20:27 +0200 Subject: [PATCH 2/8] Use pathlib glob for file filter --- pylint/testutils/functional/test_file.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pylint/testutils/functional/test_file.py b/pylint/testutils/functional/test_file.py index 402ee065eb..4a5ce1cf53 100644 --- a/pylint/testutils/functional/test_file.py +++ b/pylint/testutils/functional/test_file.py @@ -5,10 +5,10 @@ from __future__ import annotations import configparser -import os import sys from collections.abc import Callable from os.path import basename, exists, join +from pathlib import Path from typing import TypedDict @@ -101,18 +101,11 @@ def module(self) -> str: @property def expected_output(self) -> str: - files = list( - filter( - lambda s: s.startswith(f"{self.base}.") and s.endswith(".txt"), - os.listdir(self._directory), - ) - ) + files = [p.stem for p in Path(self._directory).glob(f"{self.base}.[0-9]*.txt")] # pylint: disable-next=bad-builtin current_version = int("".join(map(str, sys.version_info[:2]))) output_options = [ - int(version) - for s in files - if s.count(".") == 2 and (version := s.rsplit(".", maxsplit=2)[1]).isalnum() + int(version) for s in files if (version := s.rpartition(".")[2]).isalnum() ] for opt in sorted(output_options, reverse=True): if current_version >= opt: From 4cd00adb85a44720ed1ec325c4f5bfeae077102e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 May 2025 12:39:27 +0200 Subject: [PATCH 3/8] Add changelog and documentation --- .../contributor_guide/tests/writing_test.rst | 21 +++++++++++++++++++ doc/whatsnew/fragments/10382.internal | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 doc/whatsnew/fragments/10382.internal diff --git a/doc/development_guide/contributor_guide/tests/writing_test.rst b/doc/development_guide/contributor_guide/tests/writing_test.rst index 481bd27cef..55822b8c94 100644 --- a/doc/development_guide/contributor_guide/tests/writing_test.rst +++ b/doc/development_guide/contributor_guide/tests/writing_test.rst @@ -66,6 +66,27 @@ test runner. The following options are currently supported: - "except_implementations": List of python implementations on which the test should not run - "exclude_platforms": List of operating systems on which the test should not run +**Different output for different Python versions** + +Sometimes the linting result can change between Python releases. In these cases errors can be marked as conditional. +Supported operators are ``<``, ``<=``, ``>`` and ``>=``. + +.. code-block:: python + + def some_func() -> X: # <3.14:[undefined-variable] + ... + + class X: ... + +Since the output messages are different, it is necessary to add two separate files for it. +First ``.314.txt``, this will include the output messages for ``>=3.14``, i.e. should be empty here. +Second ``.txt``, this will be the default for all other Python versions. + +.. note:: + + This does only work if the code itself is valid in all tested Python versions. + For new syntax, use ``min_pyver`` / ``max_pyver`` instead. + **Functional test file locations** For existing checkers, new test cases should preferably be appended to the existing test file. diff --git a/doc/whatsnew/fragments/10382.internal b/doc/whatsnew/fragments/10382.internal new file mode 100644 index 0000000000..126aaf8f87 --- /dev/null +++ b/doc/whatsnew/fragments/10382.internal @@ -0,0 +1,3 @@ +Modified test framework to allow for different test output for different Python versions. + +Refs #10382 From 5a7ea64418a76ef720cc8461f4333049fd26ef46 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 May 2025 14:08:28 +0200 Subject: [PATCH 4/8] Fix tests --- pylint/testutils/functional/test_file.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pylint/testutils/functional/test_file.py b/pylint/testutils/functional/test_file.py index 4a5ce1cf53..8c7057ccbe 100644 --- a/pylint/testutils/functional/test_file.py +++ b/pylint/testutils/functional/test_file.py @@ -7,7 +7,7 @@ import configparser import sys from collections.abc import Callable -from os.path import basename, exists, join +from os.path import basename, exists, join, split from pathlib import Path from typing import TypedDict @@ -101,7 +101,10 @@ def module(self) -> str: @property def expected_output(self) -> str: - files = [p.stem for p in Path(self._directory).glob(f"{self.base}.[0-9]*.txt")] + files = [ + p.stem + for p in Path(self._directory).glob(f"{split(self.base)[-1]}.[0-9]*.txt") + ] # pylint: disable-next=bad-builtin current_version = int("".join(map(str, sys.version_info[:2]))) output_options = [ From d0454ae38ec268b38c12f884e953188f689267ba Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 May 2025 14:15:26 +0200 Subject: [PATCH 5/8] Remove unnecessary testoption --- tests/functional/ext/typing/unnecessary_default_type_args.rc | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/functional/ext/typing/unnecessary_default_type_args.rc b/tests/functional/ext/typing/unnecessary_default_type_args.rc index 910f36995a..825e13ec0b 100644 --- a/tests/functional/ext/typing/unnecessary_default_type_args.rc +++ b/tests/functional/ext/typing/unnecessary_default_type_args.rc @@ -1,5 +1,2 @@ [main] load-plugins=pylint.extensions.typing - -[testoptions] -min_pyver=3.10 From 8b71ccc2dd53d0bf80064a3453fa82c400d7a474 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 19 May 2025 00:08:13 +0200 Subject: [PATCH 6/8] Add tests and fix edge cases --- pylint/testutils/functional/test_file.py | 15 +++-- tests/testutils/test_functional_testutils.py | 58 +++++++++++++++++++- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/pylint/testutils/functional/test_file.py b/pylint/testutils/functional/test_file.py index 8c7057ccbe..342ced44bf 100644 --- a/pylint/testutils/functional/test_file.py +++ b/pylint/testutils/functional/test_file.py @@ -9,7 +9,9 @@ from collections.abc import Callable from os.path import basename, exists, join, split from pathlib import Path -from typing import TypedDict +from typing import Final, TypedDict + +_CURRENT_VERSION: Final = sys.version_info[:2] def parse_python_version(ver_str: str) -> tuple[int, ...]: @@ -105,14 +107,15 @@ def expected_output(self) -> str: p.stem for p in Path(self._directory).glob(f"{split(self.base)[-1]}.[0-9]*.txt") ] - # pylint: disable-next=bad-builtin - current_version = int("".join(map(str, sys.version_info[:2]))) output_options = [ - int(version) for s in files if (version := s.rpartition(".")[2]).isalnum() + (int(version[0]), int(version[1:])) + for s in files + if (version := s.rpartition(".")[2]).isalnum() ] for opt in sorted(output_options, reverse=True): - if current_version >= opt: - return join(self._directory, f"{self.base}.{opt}.txt") + if _CURRENT_VERSION >= opt: + str_opt = "".join([str(s) for s in opt]) + return join(self._directory, f"{self.base}.{str_opt}.txt") return join(self._directory, self.base + ".txt") @property diff --git a/tests/testutils/test_functional_testutils.py b/tests/testutils/test_functional_testutils.py index d1047c8ee1..6cf0d37c5e 100644 --- a/tests/testutils/test_functional_testutils.py +++ b/tests/testutils/test_functional_testutils.py @@ -4,8 +4,13 @@ """Tests for the functional test framework.""" +import contextlib +import os +import shutil +import tempfile +from collections.abc import Iterator from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from _pytest.outcomes import Skipped @@ -20,6 +25,26 @@ DATA_DIRECTORY = HERE / "data" +@contextlib.contextmanager +def tempdir() -> Iterator[str]: + """Create a temp directory and change the current location to it. + + This is supposed to be used with a *with* statement. + """ + tmp = tempfile.mkdtemp() + + # Get real path of tempfile, otherwise test fail on mac os x + current_dir = os.getcwd() + os.chdir(tmp) + abs_tmp = os.path.abspath(".") + + try: + yield abs_tmp + finally: + os.chdir(current_dir) + shutil.rmtree(abs_tmp) + + @pytest.fixture(name="pytest_config") def pytest_config_fixture() -> MagicMock: def _mock_getoption(option: str) -> bool: @@ -69,6 +94,37 @@ def test_get_functional_test_files_from_crowded_directory() -> None: assert "max_overflow" not in str(exc_info.value) +@pytest.mark.parametrize( + ["files", "output_file_name"], + [ + ([], "file.txt"), + (["file.txt"], "file.txt"), + (["file.314.txt"], "file.txt"), # don't match 3.14 + (["file.42.txt"], "file.txt"), # don't match 4.2 + (["file.32.txt", "file.txt"], "file.32.txt"), + (["file.312.txt", "file.txt"], "file.312.txt"), + (["file.313.txt", "file.txt"], "file.313.txt"), + (["file.310.txt", "file.313.txt", "file.312.txt", "file.txt"], "file.313.txt"), + # don't match other test file names accidentally + ([".file.313.txt"], "file.txt"), + (["file_other.313.txt"], "file.txt"), + (["other_file.313.txt"], "file.txt"), + ], +) +def test_expected_output_file_matching(files: list[str], output_file_name: str) -> None: + """Test output file matching. Pin current Python version to 3.13.""" + with tempdir(): + for file in files: + with open(file, "w", encoding="utf-8"): + ... + test_file = FunctionalTestFile(".", "file.py") + with patch( + "pylint.testutils.functional.test_file._CURRENT_VERSION", + new=(3, 13), + ): + assert test_file.expected_output == f"./{output_file_name}" + + def test_minimal_messages_config_enabled(pytest_config: MagicMock) -> None: """Test that all messages not targeted in the functional test are disabled when running with --minimal-messages-config. From e2528113d2de537d90aefd032e961740815bd6d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 19 May 2025 00:15:19 +0200 Subject: [PATCH 7/8] Improve documentation --- .../contributor_guide/tests/writing_test.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/development_guide/contributor_guide/tests/writing_test.rst b/doc/development_guide/contributor_guide/tests/writing_test.rst index 55822b8c94..5c31075d4a 100644 --- a/doc/development_guide/contributor_guide/tests/writing_test.rst +++ b/doc/development_guide/contributor_guide/tests/writing_test.rst @@ -76,6 +76,11 @@ Supported operators are ``<``, ``<=``, ``>`` and ``>=``. def some_func() -> X: # <3.14:[undefined-variable] ... + # It can also be combined with offsets + # +1:<3.14:[undefined-variable] + def some_other_func() -> X: + ... + class X: ... Since the output messages are different, it is necessary to add two separate files for it. @@ -84,7 +89,7 @@ Second ``.txt``, this will be the default for all other Python v .. note:: - This does only work if the code itself is valid in all tested Python versions. + This does only work if the code itself is parsable in all tested Python versions. For new syntax, use ``min_pyver`` / ``max_pyver`` instead. **Functional test file locations** From 6a530b9d747ec9da1dcb8d395b518501df55be18 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 19 May 2025 00:36:07 +0200 Subject: [PATCH 8/8] Fix windows tests --- tests/testutils/test_functional_testutils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/testutils/test_functional_testutils.py b/tests/testutils/test_functional_testutils.py index 6cf0d37c5e..0d332f02d9 100644 --- a/tests/testutils/test_functional_testutils.py +++ b/tests/testutils/test_functional_testutils.py @@ -6,6 +6,7 @@ import contextlib import os +import os.path import shutil import tempfile from collections.abc import Iterator @@ -122,7 +123,7 @@ def test_expected_output_file_matching(files: list[str], output_file_name: str) "pylint.testutils.functional.test_file._CURRENT_VERSION", new=(3, 13), ): - assert test_file.expected_output == f"./{output_file_name}" + assert test_file.expected_output == f".{os.path.sep}{output_file_name}" def test_minimal_messages_config_enabled(pytest_config: MagicMock) -> None: