Repository: pylint-dev/pylint

Issue:
With Pylint 3.2.4 and Python 3.8 getting incorrect error W0101: Unreachable code (unreachable)
### Bug description

When using pylint 3.2.4 I'm getting "W0101: Unreachable code (unreachable" errors but only for Python 3.8. They do not happen for Python 3.9 to 3.12.

See https://github.com/BittyTax/BittyTax/actions/runs/9677156397/job/26698206145.

Worked fine for pylint 3.2.3, see https://github.com/BittyTax/BittyTax/actions/runs/9667255227/job/26668643763.

### Command used

```shell
pylint $(git ls-files '*.py')
```


### Pylint output

```python
src/bittytax/price/datasource.py:245:8: W0101: Unreachable code (unreachable)
43
src/bittytax/price/datasource.py:253:8: W0101: Unreachable code (unreachable)
44
src/bittytax/price/datasource.py:268:8: W0101: Unreachable code (unreachable)
45
src/bittytax/price/datasource.py:342:8: W0101: Unreachable code (unreachable)
46
src/bittytax/price/datasource.py:357:8: W0101: Unreachable code (unreachable)
47
src/bittytax/price/datasource.py:384:8: W0101: Unreachable code (unreachable)
48
src/bittytax/price/datasource.py:402:8: W0101: Unreachable code (unreachable)
49
src/bittytax/price/datasource.py:423:8: W0101: Unreachable code (unreachable)
50
src/bittytax/price/datasource.py:438:8: W0101: Unreachable code (unreachable)
51
src/bittytax/price/datasource.py:455:8: W0101: Unreachable code (unreachable)
52
src/bittytax/price/datasource.py:475:8: W0101: Unreachable code (unreachable)
53
src/bittytax/price/datasource.py:495:8: W0101: Unreachable code (unreachable)
54
src/bittytax/price/datasource.py:518:8: W0101: Unreachable code (unreachable)
55
src/bittytax/price/datasource.py:539:8: W0101: Unreachable code (unreachable)
56
src/bittytax/price/datasource.py:558:8: W0101: Unreachable code (unreachable)
57
src/bittytax/price/datasource.py:584:8: W0101: Unreachable code (unreachable)
```


### Expected behavior

No errors and per pylint 3.2.3.

### Pylint version

```shell
astroid-3.2.2 pylint-3.2.4
```


### OS / Environment

Ubuntu  22.04.4 LTS




Discussion:
I don't have access to laptop right now, but can somebody bisect if this is due to https://github.com/pylint-dev/pylint/pull/9714?
Seeing the same in QEMU's tests: under Python 3.9 through 3.12 inclusive, there's no issue. Python 3.8 with 3.1.1, 3.2.0, 3.2.1, 3.2.2, 3.2.3 all work correctly. This only seems to surface directly under pylint 3.2.4 and python 3.8. Python 3.7 and earlier untested.

Bisecting confirms commit c41c35a8e0dd71ba96944613e6a2ced830407670

here's a somewhat minimized reproducer: I tried testing it one function at a time, but could only trigger it in the caller to `qemu_img_json()`.

```py
import json
import subprocess
from typing import Any

def qemu_tool(
    *args: str,
    check: bool = True,
    combine_stdio: bool = True
) -> 'subprocess.CompletedProcess[str]':
    subp = subprocess.run(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT if combine_stdio else subprocess.PIPE,
        universal_newlines=True,
        check=False
    )

    if check and subp.returncode or (subp.returncode < 0):
        raise subprocess.CalledProcessError(
            subp.returncode, args,
            output=subp.stdout,
            stderr=subp.stderr,
        )

    return subp

def qemu_img(
    *args: str,
    check: bool = True,
    combine_stdio: bool = True
) -> 'subprocess.CompletedProcess[str]':
    full_args = ['qemu-img'] + list(args)
    return qemu_tool(*full_args, check=check, combine_stdio=combine_stdio)

def qemu_img_json(*args: str) -> Any:
    try:
        res = qemu_img(*args, combine_stdio=False)
    except subprocess.CalledProcessError as exc:
        if exc.returncode < 0:
            raise
        try:
            return json.loads(exc.stdout)
        except json.JSONDecodeError:
            pass
        raise
    return json.loads(res.stdout)

def qemu_img_info(*args: str) -> Any:
    ret = qemu_img_json('info', "--output", "json", *args)
    print("hello?")  # repro.py:50:4: W0101: Unreachable code (unreachable)
    return ret
```

... If I had to guess, it's because `_SpecialForm` is being checked in 3.8, but I believe that typing.Any also inherits `_SpecialForm`, so I think that might be what's going on here.

Yup. Here's a much shorter reproducer:

```py
from typing import Any

def repro() -> Any:
    return 5

def main():
    x = repro() + 5
    print(x)
```
Thank you for the reproducer @jnsnow ! I don't have python 3.8 locally, but I've opened #9753 to reproduce in the continuous integration.



Patch:
diff --git a/doc/whatsnew/fragments/9751.bugfix b/doc/whatsnew/fragments/9751.bugfix
new file mode 100644
index 0000000000..ddd37ac2b0
--- /dev/null
+++ b/doc/whatsnew/fragments/9751.bugfix
@@ -0,0 +1,4 @@
+Fixed a false positive ``unreachable-code`` when using ``typing.Any`` as return type in python
+3.8, the ``typing.NoReturn`` are not taken into account anymore for python 3.8 however.
+
+Closes #9751
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index c49487981d..d2e9cd6b96 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -2184,7 +2184,9 @@ def is_terminating_func(node: nodes.Call) -> bool:
                     *TYPING_NEVER,
                     *TYPING_NORETURN,
                     # In Python 3.7 - 3.8, NoReturn is alias of '_SpecialForm'
-                    "typing._SpecialForm",
+                    # "typing._SpecialForm",
+                    # But 'typing.Any' also inherits _SpecialForm
+                    # See #9751
                 )
             ):
                 return True


Test patch:
diff --git a/tests/functional/r/regression_02/regression_9751.py b/tests/functional/r/regression_02/regression_9751.py
new file mode 100644
index 0000000000..c3b9d787f2
--- /dev/null
+++ b/tests/functional/r/regression_02/regression_9751.py
@@ -0,0 +1,15 @@
+"""
+pylint 3.2.4 regression
+https://github.com/pylint-dev/pylint/issues/9751
+"""
+
+# pylint: disable=missing-function-docstring
+
+from typing import Any
+
+def repro() -> Any:
+    return 5
+
+def main():
+    x = repro() + 5
+    print(x)
diff --git a/tests/functional/u/used/used_before_assignment.py b/tests/functional/u/used/used_before_assignment.py
index 37da03d7e5..5e589e81bb 100644
--- a/tests/functional/u/used/used_before_assignment.py
+++ b/tests/functional/u/used/used_before_assignment.py
@@ -2,7 +2,7 @@
 # pylint: disable=consider-using-f-string, missing-function-docstring
 import datetime
 import sys
-from typing import NoReturn
+# from typing import NoReturn   # uncomment when we reunite with used_before_assignment_py38.py
 
 MSG = "hello %s" % MSG  # [used-before-assignment]
 
@@ -206,19 +206,3 @@ def inner_if_continues_outer_if_has_no_other_statements():
         else:
             order = None
         print(order)
-
-
-class PlatformChecks:
-    """https://github.com/pylint-dev/pylint/issues/9674"""
-    def skip(self, msg) -> NoReturn:
-        raise Exception(msg)  # pylint: disable=broad-exception-raised
-
-    def print_platform_specific_command(self):
-        if sys.platform == "linux":
-            cmd = "ls"
-        elif sys.platform == "win32":
-            cmd = "dir"
-        else:
-            self.skip("only runs on Linux/Windows")
-
-        print(cmd)
diff --git a/tests/functional/u/used/used_before_assignment_py38.py b/tests/functional/u/used/used_before_assignment_py38.py
new file mode 100644
index 0000000000..81d69268e2
--- /dev/null
+++ b/tests/functional/u/used/used_before_assignment_py38.py
@@ -0,0 +1,26 @@
+"""
+Temporary file until we drop python 3.8
+See https://github.com/pylint-dev/pylint/issues/9751
+Please reunite with used_before_assignment.py at this point
+"""
+
+# pylint: disable=missing-docstring
+
+import sys
+from typing import NoReturn
+
+
+class PlatformChecks:
+    """https://github.com/pylint-dev/pylint/issues/9674"""
+    def skip(self, msg) -> NoReturn:
+        raise Exception(msg)  # pylint: disable=broad-exception-raised
+
+    def print_platform_specific_command(self):
+        if sys.platform == "linux":
+            cmd = "ls"
+        elif sys.platform == "win32":
+            cmd = "dir"
+        else:
+            self.skip("only runs on Linux/Windows")
+
+        print(cmd)
diff --git a/tests/functional/u/used/used_before_assignment_py38.rc b/tests/functional/u/used/used_before_assignment_py38.rc
new file mode 100644
index 0000000000..16b75eea75
--- /dev/null
+++ b/tests/functional/u/used/used_before_assignment_py38.rc
@@ -0,0 +1,2 @@
+[testoptions]
+min_pyver=3.9