Skip to content

calls to functions returning Never/NoReturn are terminal #180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
sharkdp opened this issue Mar 13, 2025 · 9 comments · May be fixed by astral-sh/ruff#18333
Open

calls to functions returning Never/NoReturn are terminal #180

sharkdp opened this issue Mar 13, 2025 · 9 comments · May be fixed by astral-sh/ruff#18333
Labels
bug Something isn't working control flow
Milestone

Comments

@sharkdp
Copy link
Contributor

sharkdp commented Mar 13, 2025

Summary

The following code emits a invalid-return-type diagnostic, but shouldn't:

from typing import NoReturn
import sys

def f() -> NoReturn:
#          ^^^^^^^^ Function can implicitly return `None`, which is not assignable to return type `Never`
    sys.exit(1)

sys.exit is annotated with returning NoReturn, but the problem is that we don't have an explicit return statement here.

This is related to unreachable code analysis. We already have some special handling for detecting whether or not the end of scope is reachable (i.e. replacing sys.exit(1) with while True: pass works fine), but this does not take calls to functions returning Never/NoReturn into account.

@sharkdp sharkdp added the bug Something isn't working label Mar 13, 2025
@sharkdp sharkdp changed the title [red-knot] Return type checking in presence of calls to Never/NoReturn [red-knot] Return type checking for Never/NoReturn Mar 13, 2025
@abhijeetbodas2001
Copy link

abhijeetbodas2001 commented Mar 15, 2025

Hi David, I would like to work on this. Would this be an OK first issue to pick up?

@sharkdp
Copy link
Contributor Author

sharkdp commented Mar 15, 2025

You are certainly welcome to try! However, I do not think that this is a particularly beginner friendly task. We might have some issues labeled with "help wanted" that might be more suitable for a first contribution.

@abhijeetbodas2001
Copy link

abhijeetbodas2001 commented Mar 15, 2025

Got it. I will try to find another issue, or another minor task in this area (I do see some TODOs in the tests, for example).

However, one thing is not clear to me. You mention in the issue description that replacing sys.exit(1) with while True: pass does not give an error. I tried that out, and indeed there is no error.

However, the following also does not give any error:

def f() -> int:
  while True:
    pass

(Notice that I changed the return value to int.)

Here, if the "while True never returns anything" inference was being done correctly, the above should have errored out, right?

Moreover, the following gives an error:

# "Object of type `Literal["foo"]` is not assignable to return type `int`"
def f() -> int:
  while True:
    pass

  return "foo"

... which makes me wonder if red-knot does not correctly understand the while True: pass case at all. Am I missing something?

Edit: looking at astral-sh/ruff#15676, which introduced the unreachability-detection, I don't see a "while True with no break" condition.

@carljm carljm changed the title [red-knot] Return type checking for Never/NoReturn [red-knot] calls to functions returning Never/NoReturn are terminal Mar 26, 2025
@abhijeetbodas2001
Copy link

However, the following also does not give any error:

def f() -> int:
while True:
pass

(Notice that I changed the return value to int.)

Because there are no reachable return statements, and the end of function scope is not visible, it makes sense that there are no diagnostics emitted currently, because we aren't checking the return value at all.

However, in this case, the correct thing to do would be to enforce that the function's annotated return type is Never, isn't it?
@sharkdp does this seem fit for a separate issue?

@sharkdp
Copy link
Contributor Author

sharkdp commented Apr 9, 2025

@abhijeetbodas2001 Sorry for responding so late, I must have missed the notification about your original message while I was out on vacation.

Here, if the "while True never returns anything" inference was being done correctly, the above should have errored out, right?

I don't think so. A function annotated with int that loops endlessly is not incorrect. You can think of the endless loop as returning Never, but Never is assignable to the return type int (Never is assignable to any type), so nothing wrong there.

Moreover, the following gives an error:

Yes, we don't silence all errors in unreachable code. And in this case, it's arguable if that would be an improvement. If the loop were not endless, that return statement would be wrong. So flagging it does not seem like unwanted behavior to me.

@abhijeetbodas2001
Copy link

(Never is assignable to any type)

Ah! I missed this. That makes sense then. Thanks!

@AlexWaygood AlexWaygood marked this as a duplicate of astral-sh/ruff#17814 May 3, 2025
@Bobronium Bobronium marked this as a duplicate of astral-sh/ruff#17911 May 7, 2025
@MichaReiser MichaReiser transferred this issue from astral-sh/ruff May 7, 2025
@MichaReiser MichaReiser changed the title [red-knot] calls to functions returning Never/NoReturn are terminal calls to functions returning Never/NoReturn are terminal May 7, 2025
@WAKayser
Copy link

WAKayser commented May 8, 2025

Currently NoReturn cannot be used to narrow a type. Which is a relatively common pattern in for example Flask examples. Where abort() can be used to return an error code early. This can be used to make sure that the request contains valid data.

For reference here is another example that currently doesnt work.

from typing import NoReturn, reveal_type

def raise_error() -> NoReturn:
    raise Exception('test')


def test(x: str | None) -> None:
    if x is None:
        raise_error()
    
    reveal_type(x)
    # Should be "str" is "str | None"

@Avasam
Copy link

Avasam commented May 18, 2025

Same issue, different symptoms. I have this simplified example that's causing warning[possibly-unbound-attribute]: Attribute ignoreon typeQCloseEvent | None is possibly unbound since ty doesn't see that

from PySide6 import QtGui
from PySide6.QtWidgets import QMainWindow

class MainWindow(QMainWindow):
    def closeEvent(self, event: QtGui.QCloseEvent | None = None):
        """Exit safely when closing the window."""

        if event is None or False:  # `or False` is actually some other condition, simplified for this example
            sys.exit(1)  # Here ty should see that `event` can't be `None` if this branch wasn't taken

        # ... some more checks and user prompts happened here

        # Fallthrough case: Prevent program from closing.
        event.ignore()

@AlexWaygood AlexWaygood marked this as a duplicate of #454 May 19, 2025
@ilius
Copy link

ilius commented May 23, 2025

Also for os.execl, os.execle, os.execlp, os.execlpe, os.execvp, os.execvpe.

Funcs in os.py that mention "replacing the current process" in their doc.

def restartLow() -> typing.NoReturn:
	"""Will not return from the function."""
	os.execl(
		sys.executable,
		sys.executable,
		*sys.argv,
	)
def execl(file, *args):
    """execl(file, *args)

    Execute the executable file with argument list args, replacing the
    current process. """
    execv(file, args)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working control flow
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants