Repository: pylint-dev/pylint

Issue:
False positive: `possibly-used-before-assignment` and `pytest.skip` (and other `NoReturn` functions)
### Bug description

```python
import pytest
import sys

def test_something():
    if sys.platform == "linux":
        cmd = "ls"
    elif sys.platform == "win32":
        cmd = "dir"
    else:
        pytest.skip("only runs on Linux/Windows")

    print(cmd)
```


### Configuration

_No response_

### Command used

```shell
pylint -E test_x.py
```


### Pylint output

```python
************* Module test_x
test_x.py:12:10: E0606: Possibly using variable 'cmd' before assignment (possibly-used-before-assignment)
```


### Expected behavior

No error: The `pytest` module has a couple of functions that never return:

- `pytest.exit(...)`
- `pytest.skip(...)`
- `pytest.fail(...)`
- `pytest.xfail(...)`

Related:

- #9627 
- #9643 

---

Instead of maintaining a list of functions like this, could pylint maybe instead check whether they have a [`NoReturn` return type](https://docs.python.org/3/library/typing.html#typing.NoReturn), if type information is available? That would cover `assert_never()`, all the pytest functions, possibly `sys.exit()` and friends (if stdlib typing information is available via typeshed), and lots and lots of other third-party cases like this.

### Pylint version

```shell
pylint 3.2.1
astroid 3.2.2
Python 3.12.3 (main, Apr 23 2024, 09:16:07) [GCC 13.2.1 20240417]
```


### OS / Environment

Archlinux

### Additional dependencies

_No response_


Discussion:
Agree we should do this, and also update the docs for `--never-returning-functions` at the same time.
I was wondering if it should be in pylint-pytest only but it's easy enough to do to be in pylint directly. (@stdedos fyi)
Sure, why not? In pylint context, it does feel like a https://github.com/pylint-dev/pylint-pytest thing
I still think this should be more generic than hardcoding pytest methods. I had another scenario in my codebase where I have something like:

```python
def _raise_invalid_node(...) -> NoReturn:
    # do some additional work
    raise Exception(...)

def func():
    if ...:
        var = 1
    else:
        _raise_invalid_node(...)

    print(var)
```

and I'd expect pylint to know that `var` is always defined here, because `_raise_invalid_node` is declared to never return.
> I still think this should be more generic than hardcoding pytest methods.

I don't object the correct-ness of your statement. What concerns me is how much should pylint enter the fields of mypy 😅 

I don't know how strong pylint is / invested in data flow analysis 
> What concerns me is how much should pylint enter the fields of mypy 

There's a lot of work to start taking typing into account in pylint (generically at least). Hypothetically, pylint would infer first, and use typing if inference fail (#4813)

Making inference handle a calls that always exit well is something to consider for sure. I don't know if we have an open issue for that. But the reasonable short term solution is to pre-populate ``--never-returning-functions`` with the most common/used one as Jacob said. 

> But the reasonable short term solution is to pre-populate `--never-returning-functions` with the most common/used one as Jacob said.

What if pylint does the short-term solution, and then pylint-pytest builds on top of that, and auto-inject more `--never-returning-functions` scoped at the `test/` directory?
There are great test cases for this in https://github.com/pylint-dev/pylint/issues/9692
> What concerns me is how much should pylint enter the fields of mypy 😅

Yes, there is not enough volunteer labor nor would it be a wise use of it to emulate mypy in pylint. Part of pylint's value prop is "not trusting your typing", making it especially useful for legacy/untyped codebases. (see "realist pylint user" quoted in the repo readme)

However... it strikes me that that's still consistent with observing declarations like `NoReturn` in order to avoid asking users to go through the more friction-ful path of scouring their code bases and dependencies for NoReturn and syncing it up with their config file. Observing a well-known return annotation is different than doing full-fledged type analysis.
***
This diff makes the false positive go away (someone should kick the tires and make sure it's right):

```diff
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index a3e649651..a7938528a 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -2165,6 +2165,13 @@ def is_terminating_func(node: nodes.Call) -> bool:
                 and inferred.qname() in TERMINATING_FUNCS_QNAMES
             ):
                 return True
+            if (
+                isinstance(inferred, nodes.FunctionDef)
+                and isinstance(inferred.returns, nodes.Name)
+                and (inferred_func := inferred.returns.inferred()[0])
+                and (inferred_func.qname() == 'typing.NoReturn')
+            ):
+                return True
     except (StopIteration, astroid.InferenceError):
         pass
```
***
@stdedos would you potentially be interested in being the person to kick the tires? Make sure that's right, and get a test and changelog added?
> @stdedos would you potentially be interested in being the person to kick the tires? Make sure that's right, and get a test and changelog added?

I would love to be that person. ... However, I have "lack-of-knowledge" of my own that I need to kick sometime (https://github.com/pylint-dev/pylint-pytest/issues/68), and also "lack-of-time" these days 😕 




Patch:
diff --git a/doc/data/messages/p/possibly-used-before-assignment/details.rst b/doc/data/messages/p/possibly-used-before-assignment/details.rst
index 1136b6965a..6ebce4a3e2 100644
--- a/doc/data/messages/p/possibly-used-before-assignment/details.rst
+++ b/doc/data/messages/p/possibly-used-before-assignment/details.rst
@@ -17,6 +17,11 @@ You can use ``assert_never`` to mark exhaustive choices:
     if suffix in "dmy":
         handle_date_suffix(suffix)
 
+Or, instead of `assert_never()`, you can call a function with a return
+annotation of `Never` or `NoReturn`. Unlike in the general case, where
+by design pylint ignores type annotations and does its own static analysis,
+here, pylint treats these special annotations like a disable comment.
+
 Pylint currently allows repeating the same test like this, even though this
 lets some error cases through, as pylint does not assess the intervening code:
 
diff --git a/doc/whatsnew/fragments/9674.false_positive b/doc/whatsnew/fragments/9674.false_positive
new file mode 100644
index 0000000000..d1e3fb6814
--- /dev/null
+++ b/doc/whatsnew/fragments/9674.false_positive
@@ -0,0 +1,5 @@
+Prevent emitting ``possibly-used-before-assignment`` when relying on names
+only potentially not defined in conditional blocks guarded by functions
+annotated with ``typing.Never`` or ``typing.NoReturn``.
+
+Closes #9674
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index b1cadda1d6..c49487981d 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -25,6 +25,8 @@
 from astroid.nodes._base_nodes import ImportNode, Statement
 from astroid.typing import InferenceResult, SuccessfulInferenceResult
 
+from pylint.constants import TYPING_NEVER, TYPING_NORETURN
+
 if TYPE_CHECKING:
     from functools import _lru_cache_wrapper
 
@@ -2150,7 +2152,9 @@ def is_singleton_const(node: nodes.NodeNG) -> bool:
 
 
 def is_terminating_func(node: nodes.Call) -> bool:
-    """Detect call to exit(), quit(), os._exit(), or sys.exit()."""
+    """Detect call to exit(), quit(), os._exit(), sys.exit(), or
+    functions annotated with `typing.NoReturn` or `typing.Never`.
+    """
     if (
         not isinstance(node.func, nodes.Attribute)
         and not (isinstance(node.func, nodes.Name))
@@ -2165,6 +2169,25 @@ def is_terminating_func(node: nodes.Call) -> bool:
                 and inferred.qname() in TERMINATING_FUNCS_QNAMES
             ):
                 return True
+            # Unwrap to get the actual function node object
+            if isinstance(inferred, astroid.BoundMethod) and isinstance(
+                inferred._proxied, astroid.UnboundMethod
+            ):
+                inferred = inferred._proxied._proxied
+            if (
+                isinstance(inferred, nodes.FunctionDef)
+                and isinstance(inferred.returns, nodes.Name)
+                and (inferred_func := safe_infer(inferred.returns))
+                and hasattr(inferred_func, "qname")
+                and inferred_func.qname()
+                in (
+                    *TYPING_NEVER,
+                    *TYPING_NORETURN,
+                    # In Python 3.7 - 3.8, NoReturn is alias of '_SpecialForm'
+                    "typing._SpecialForm",
+                )
+            ):
+                return True
     except (StopIteration, astroid.InferenceError):
         pass
 
diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py
index 8e8b0e0bfb..bfc80697c4 100644
--- a/pylint/checkers/variables.py
+++ b/pylint/checkers/variables.py
@@ -2630,7 +2630,7 @@ def _loopvar_name(self, node: astroid.Name) -> None:
                 else_stmt, (nodes.Return, nodes.Raise, nodes.Break, nodes.Continue)
             ):
                 return
-            # TODO: 4.0: Consider using RefactoringChecker._is_function_def_never_returning
+            # TODO: 4.0: Consider using utils.is_terminating_func
             if isinstance(else_stmt, nodes.Expr) and isinstance(
                 else_stmt.value, nodes.Call
             ):


Test patch:
diff --git a/tests/functional/u/used/used_before_assignment.py b/tests/functional/u/used/used_before_assignment.py
index c706af043f..37da03d7e5 100644
--- a/tests/functional/u/used/used_before_assignment.py
+++ b/tests/functional/u/used/used_before_assignment.py
@@ -2,6 +2,7 @@
 # pylint: disable=consider-using-f-string, missing-function-docstring
 import datetime
 import sys
+from typing import NoReturn
 
 MSG = "hello %s" % MSG  # [used-before-assignment]
 
@@ -205,3 +206,19 @@ 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.txt b/tests/functional/u/used/used_before_assignment.txt
index 6f97f4324c..eacf082399 100644
--- a/tests/functional/u/used/used_before_assignment.txt
+++ b/tests/functional/u/used/used_before_assignment.txt
@@ -1,15 +1,15 @@
-used-before-assignment:6:19:6:22::Using variable 'MSG' before assignment:HIGH
-used-before-assignment:8:20:8:24::Using variable 'MSG2' before assignment:HIGH
-used-before-assignment:11:4:11:9:outer:Using variable 'inner' before assignment:HIGH
-used-before-assignment:20:20:20:40:ClassWithProperty:Using variable 'redefine_time_import' before assignment:HIGH
-used-before-assignment:24:0:24:9::Using variable 'calculate' before assignment:HIGH
-used-before-assignment:32:10:32:14:redefine_time_import:Using variable 'time' before assignment:HIGH
-used-before-assignment:46:3:46:7::Using variable 'VAR2' before assignment:INFERENCE
-possibly-used-before-assignment:64:3:64:7::Possibly using variable 'VAR4' before assignment:INFERENCE
-possibly-used-before-assignment:74:3:74:7::Possibly using variable 'VAR5' before assignment:INFERENCE
-used-before-assignment:79:3:79:7::Using variable 'VAR6' before assignment:INFERENCE
-used-before-assignment:114:6:114:11::Using variable 'VAR10' before assignment:INFERENCE
-possibly-used-before-assignment:120:6:120:11::Possibly using variable 'VAR12' before assignment:CONTROL_FLOW
-used-before-assignment:151:10:151:14::Using variable 'SALE' before assignment:INFERENCE
-used-before-assignment:183:10:183:18::Using variable 'ALL_DONE' before assignment:INFERENCE
-used-before-assignment:194:6:194:24::Using variable 'NOT_ALWAYS_DEFINED' before assignment:INFERENCE
+used-before-assignment:7:19:7:22::Using variable 'MSG' before assignment:HIGH
+used-before-assignment:9:20:9:24::Using variable 'MSG2' before assignment:HIGH
+used-before-assignment:12:4:12:9:outer:Using variable 'inner' before assignment:HIGH
+used-before-assignment:21:20:21:40:ClassWithProperty:Using variable 'redefine_time_import' before assignment:HIGH
+used-before-assignment:25:0:25:9::Using variable 'calculate' before assignment:HIGH
+used-before-assignment:33:10:33:14:redefine_time_import:Using variable 'time' before assignment:HIGH
+used-before-assignment:47:3:47:7::Using variable 'VAR2' before assignment:INFERENCE
+possibly-used-before-assignment:65:3:65:7::Possibly using variable 'VAR4' before assignment:INFERENCE
+possibly-used-before-assignment:75:3:75:7::Possibly using variable 'VAR5' before assignment:INFERENCE
+used-before-assignment:80:3:80:7::Using variable 'VAR6' before assignment:INFERENCE
+used-before-assignment:115:6:115:11::Using variable 'VAR10' before assignment:INFERENCE
+possibly-used-before-assignment:121:6:121:11::Possibly using variable 'VAR12' before assignment:CONTROL_FLOW
+used-before-assignment:152:10:152:14::Using variable 'SALE' before assignment:INFERENCE
+used-before-assignment:184:10:184:18::Using variable 'ALL_DONE' before assignment:INFERENCE
+used-before-assignment:195:6:195:24::Using variable 'NOT_ALWAYS_DEFINED' before assignment:INFERENCE