diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst index cef8e2a72b..eb7d72f2a3 100644 --- a/doc/user_guide/configuration/all-options.rst +++ b/doc/user_guide/configuration/all-options.rst @@ -167,6 +167,13 @@ Standard Checkers **Default:** ``True`` +--prefer-stubs +"""""""""""""" +*Resolve imports to .pyi stubs if available. May reduce no-member messages and increase not-an-iterable messages.* + +**Default:** ``False`` + + --py-version """""""""""" *Minimum Python version to use for version dependent checks. Will default to the version used to run pylint.* @@ -271,6 +278,8 @@ Standard Checkers persistent = true + prefer-stubs = false + py-version = "sys.version_info[:2]" recursive = false diff --git a/doc/whatsnew/fragments/9139.internal b/doc/whatsnew/fragments/9139.internal new file mode 100644 index 0000000000..98fbbabc2c --- /dev/null +++ b/doc/whatsnew/fragments/9139.internal @@ -0,0 +1,5 @@ +Update astroid version to 3.2.1. This solves some reports of ``RecursionError`` +and also makes the *prefer .pyi stubs* feature in astroid 3.2.0 *opt-in* +with the aforementioned ``--prefer-stubs=y`` option. + +Refs #9139 diff --git a/doc/whatsnew/fragments/9626.bugfix b/doc/whatsnew/fragments/9626.bugfix new file mode 100644 index 0000000000..44b7539eac --- /dev/null +++ b/doc/whatsnew/fragments/9626.bugfix @@ -0,0 +1,9 @@ +Add `--prefer-stubs=yes` option to opt-in to the astroid 3.2 feature +that prefers `.pyi` stubs over same-named `.py` files. This has the +potential to reduce `no-member` errors but at the cost of more errors +such as `not-an-iterable` from function bodies appearing as `...`. + +Defaults to `no`. + +Closes #9626 +Closes #9623 diff --git a/examples/pylintrc b/examples/pylintrc index 4877208fe7..0a917e9cef 100644 --- a/examples/pylintrc +++ b/examples/pylintrc @@ -87,6 +87,10 @@ load-plugins= # Pickle collected data for later comparisons. persistent=yes +# Resolve imports to .pyi stubs if available. May reduce no-member messages +# and increase not-an-iterable messages. +prefer-stubs=no + # Minimum Python version to use for version dependent checks. Will default to # the version used to run pylint. py-version=3.10 diff --git a/examples/pyproject.toml b/examples/pyproject.toml index 2cd404c269..68e8c66737 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -77,6 +77,10 @@ limit-inference-results = 100 # Pickle collected data for later comparisons. persistent = true +# Resolve imports to .pyi stubs if available. May reduce no-member messages +# and increase not-an-iterable messages. +prefer-stubs = false + # Minimum Python version to use for version dependent checks. Will default to the # version used to run pylint. py-version = "3.10" diff --git a/pylint/lint/base_options.py b/pylint/lint/base_options.py index cd354c49db..59a811d9c6 100644 --- a/pylint/lint/base_options.py +++ b/pylint/lint/base_options.py @@ -415,6 +415,17 @@ def _make_linter_options(linter: PyLinter) -> Options: "Useful if running pylint in a server-like mode.", }, ), + ( + "prefer-stubs", + { + "default": False, + "type": "yn", + "metavar": "", + "help": "Resolve imports to .pyi stubs if available. May " + "reduce no-member messages and increase not-an-iterable " + "messages.", + }, + ), ) diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py index f1aca76a4e..eff15cc444 100644 --- a/pylint/lint/pylinter.py +++ b/pylint/lint/pylinter.py @@ -1074,6 +1074,7 @@ def open(self) -> None: MANAGER.max_inferable_values = self.config.limit_inference_results MANAGER.extension_package_whitelist.update(self.config.extension_pkg_allow_list) MANAGER.module_denylist.update(self.config.ignored_modules) + MANAGER.prefer_stubs = self.config.prefer_stubs if self.config.extension_pkg_whitelist: MANAGER.extension_package_whitelist.update( self.config.extension_pkg_whitelist diff --git a/pylint/utils/utils.py b/pylint/utils/utils.py index 203c543d8e..73e9e6a5f3 100644 --- a/pylint/utils/utils.py +++ b/pylint/utils/utils.py @@ -52,6 +52,7 @@ "suggestion-mode", "analyse-fallback-blocks", "allow-global-unused-variables", + "prefer-stubs", ] GLOBAL_OPTION_INT = Literal["max-line-length", "docstring-min-length"] GLOBAL_OPTION_LIST = Literal["ignored-modules"] diff --git a/pyproject.toml b/pyproject.toml index 9972af35a1..e991138b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ # Also upgrade requirements_test_min.txt. # Pinned to dev of second minor update to allow editable installs and fix primer issues, # see https://github.com/pylint-dev/astroid/issues/1341 - "astroid>=3.2.0,<=3.3.0-dev0", + "astroid>=3.2.1,<=3.3.0-dev0", "isort>=4.2.5,<6,!=5.13.0", "mccabe>=0.6,<0.8", "tomli>=1.1.0;python_version<'3.11'", diff --git a/requirements_test_min.txt b/requirements_test_min.txt index bd5228873e..c64f74f862 100644 --- a/requirements_test_min.txt +++ b/requirements_test_min.txt @@ -1,6 +1,6 @@ .[testutils,spelling] # astroid dependency is also defined in pyproject.toml -astroid==3.2.0 # Pinned to a specific version for tests +astroid==3.2.1 # Pinned to a specific version for tests typing-extensions~=4.11 py~=1.11.0 pytest~=7.4 diff --git a/tests/lint/test_pylinter.py b/tests/lint/test_pylinter.py index c14d1929af..bc12455354 100644 --- a/tests/lint/test_pylinter.py +++ b/tests/lint/test_pylinter.py @@ -59,3 +59,12 @@ def test_open_pylinter_denied_modules(linter: PyLinter) -> None: assert MANAGER.module_denylist == {"mod1", "mod2", "mod3"} finally: MANAGER.module_denylist = set() + + +def test_open_pylinter_prefer_stubs(linter: PyLinter) -> None: + try: + linter.config.prefer_stubs = True + linter.open() + assert MANAGER.prefer_stubs + finally: + MANAGER.prefer_stubs = False diff --git a/tests/lint/unittest_lint.py b/tests/lint/unittest_lint.py index 119dd847ae..18305b73a8 100644 --- a/tests/lint/unittest_lint.py +++ b/tests/lint/unittest_lint.py @@ -1049,7 +1049,7 @@ def test_by_module_statement_value(initialized_linter: PyLinter) -> None: def test_finds_pyi_file() -> None: run = Run( - [join(REGRTEST_DATA_DIR, "pyi")], + ["--prefer-stubs=y", join(REGRTEST_DATA_DIR, "pyi")], exit=False, ) assert run.linter.current_file is not None @@ -1061,6 +1061,8 @@ def test_recursive_finds_pyi_file() -> None: [ "--recursive", "y", + "--prefer-stubs", + "y", join(REGRTEST_DATA_DIR, "pyi"), ], exit=False, @@ -1069,6 +1071,20 @@ def test_recursive_finds_pyi_file() -> None: assert run.linter.current_file.endswith("foo.pyi") +def test_no_false_positive_from_pyi_stub() -> None: + run = Run( + [ + "--recursive", + "y", + "--prefer-stubs", + "n", + join(REGRTEST_DATA_DIR, "uses_module_with_stub.py"), + ], + exit=False, + ) + assert not run.linter.stats.by_msg + + @pytest.mark.parametrize( "ignore_parameter,ignore_parameter_value", [ diff --git a/tests/regrtest_data/pyi/foo.py b/tests/regrtest_data/pyi/foo.py new file mode 100644 index 0000000000..c49f18ee09 --- /dev/null +++ b/tests/regrtest_data/pyi/foo.py @@ -0,0 +1,2 @@ +def three_item_iterable(): + return [1, 2, 3] diff --git a/tests/regrtest_data/pyi/foo.pyi b/tests/regrtest_data/pyi/foo.pyi index c4e5bcc800..a84058c7c1 100644 --- a/tests/regrtest_data/pyi/foo.pyi +++ b/tests/regrtest_data/pyi/foo.pyi @@ -1 +1,4 @@ foo = 1 + +def three_item_iterable(): + ... diff --git a/tests/regrtest_data/uses_module_with_stub.py b/tests/regrtest_data/uses_module_with_stub.py new file mode 100644 index 0000000000..d7cbf63d9c --- /dev/null +++ b/tests/regrtest_data/uses_module_with_stub.py @@ -0,0 +1,5 @@ +"""If the stub is preferred over the .py, this might emit not-an-iterable""" +from pyi.foo import three_item_iterable + +for val in three_item_iterable(): + print(val) diff --git a/tests/test_functional.py b/tests/test_functional.py index df42767a5c..13087cfd6b 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -51,6 +51,7 @@ def revert_stateful_config_changes(linter: PyLinter) -> Iterator[PyLinter]: yield linter # Revert any stateful configuration changes. MANAGER.brain["module_denylist"] = set() + MANAGER.brain["prefer_stubs"] = False @pytest.mark.usefixtures("revert_stateful_config_changes")