Repository: pylint-dev/pylint

Issue:
`--ignore-patterns` for `.pyi` stubs has no effect if .py resolves to .pyi anyway
### Bug description

`tool.pylint.ignore-patterns` stopped working in pylint 3.2.0. Pylint can't handle `.pyi` files yet, it just treats them like `.py`, which causes it to report that `|` is invalid, etc.

### Configuration

```ini
[tool.pylint]
ignore-patterns = ['.*\.pyi']
```


### Command used

```shell
pylint scikit_build_core
```

(technically `nox -s pylint` in https://github.com/scikit-build/scikit-build-core)

### Pylint output

```python
src/scikit_build_core/_version.pyi:2:15: E1131: unsupported operand type(s) for | (unsupported-binary-operation)
```


### Expected behavior

It should skip the ignored files. It does if I pin 3.1.*.

### Pylint version

```shell
pylint 3.2.0
astroid 3.2.0
Python 3.12.3 (main, Apr  9 2024, 08:09:14) [Clang 15.0.0 (clang-1500.3.9.4)]
```


### OS / Environment

macOS.

### Additional dependencies

_No response_


Discussion:
The following appears to ignore the .pyi file as expected (not a list):
```python
[tool.pylint]
ignore-patterns = ".*\\.pyi"
```

patterns plural should be a list, but changing it doesn't fix it. I still get the `_version.pyi` file if I use 3.2.0 (and not with 3.1.x).

It seems to be specific, though, as in boost-histogram, if I update the pin to 3.2.*, it doesn't start picking up .pyi files (unless I delete the ignore-patterns line).

FYI, it it's helpful, here are the `.pyi` reports from boost-histogram when manually removing the supression:

* `W0613: Unused argument 'arg0' (unused-argument)`  Many of these! Arguments are never used in .pyi files
* `C0321: More than one statement on a single line (multiple-statements)` It counts `def(): ...` as more than one statement on a line!
* `W0231: __init__ method from base class '_BaseRegular' is not called (super-init-not-called)` This never happens in .pyi
* `W0406: Module import itself (import-self)` from `from . import X`, where X is a submodule - I think it's also complaining `no name X in module (no-name-in-module)` for these.


I think it found a few issues though, `static method with self as first argument` and `unused Tuple imported from typing` both seem valid.

`R0801: Similar lines in 2 files also is triggering`, pyi files have a lot of duplicate code.
I've looked over the diff https://github.com/pylint-dev/pylint/compare/v3.1.1...v3.2.0, and I really don't see what could have changed. `ignored-modules` changed a tiny bit, but otherwise, not much related to this seems to have been touched. Not sure what the bump in astroid does, though.
If I checkout the repo and run pylint as-is without changing the pyproject.toml in the project at all it already works (not sure why!):
```python
(venv312) markbyrne@Marks-Air-2 src % pylint scikit_build_core/_version.pyi                            
(venv312) markbyrne@Marks-Air-2 src % 
```

As an example, the following shows the syntax in action:
```python
(venv312) markbyrne@Marks-Air-2 src % pylint scikit_build_core/_version.pyi --ignore-patterns=".*\.pyix"
************* Module scikit_build_core._version
scikit_build_core/_version.pyi:2:15: E1131: unsupported operand type(s) for | (unsupported-binary-operation)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)

(venv312) markbyrne@Marks-Air-2 src % pylint scikit_build_core/_version.pyi --ignore-patterns=".*\.pyi" 
(venv312) markbyrne@Marks-Air-2 src % 
```
```python
(venv312) markbyrne@Marks-Air-2 src % pylint --version
pylint 3.2.0
astroid 3.2.0
Python 3.12.0
```
@mbyrnepr2 thanks for jumping on triage. I'm assuming it's the get_source_file() change we did in astroid. Did you test with a same named .py file next to the .pyi?
pylint-dev/astroid#2375
The file is at `src/scikit_build_core/_version.pyi`. If you run on `src/scikit_build_core`, you see the `.pyi` error. You need to install to see the error without `src`.

I've noticed I sometimes see two identical errors if I remove the exclusion.

```console
$ sed -i '' /ignore-patterns/d pyproject.toml
$ nox -s pylint
nox > Running session pylint
nox > Re-using existing virtual environment at .nox/pylint.
nox > uv pip install '-e.[dev,test,test-meta]' 'pylint==3.2.*'
nox > pylint --version
pylint 3.2.0
astroid 3.2.0
Python 3.12.3 (main, Apr  9 2024, 08:09:14) [Clang 15.0.0 (clang-1500.3.9.4)]
nox > pylint scikit_build_core
************* Module scikit_build_core._version
src/scikit_build_core/_version.pyi:2:15: E1131: unsupported operand type(s) for | (unsupported-binary-operation)
src/scikit_build_core/_version.pyi:2:15: E1131: unsupported operand type(s) for | (unsupported-binary-operation)

------------------------------------------------------------------
Your code has been rated at 9.97/10 (previous run: 9.51/10, +0.46)

nox > Command pylint scikit_build_core failed with exit code 2
nox > Session pylint failed.
```
Ahh, if you just run without installing, you don't have a matching `_version.py` file, that's probably why https://github.com/pylint-dev/pylint/issues/9623#issuecomment-2110548265 passed. If you install (even to any other venv, it still makes the `_version.py` file), then the problem occurs.
Thanks @henryiii. I think we should consider hotfixing a reversion of https://github.com/pylint-dev/astroid/pull/2375 in astroid. It's not really ready for primetime.

cc/ @Pierre-Sassoulas
Right indeed I can reproduce it after installing first!
(FYI, if using scikit-build-core for testing, I'll be manually adding an ignore on this line for now, since I really want pylint 3.2's GHA reporter! :D )
I agree a revert is in order.



Patch:
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": "<y or n>",
+                "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'",


Test patch:
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 +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")