Repository: cython/cython

Issue:
[ENH] provide command line option for enabling legacy noexcept propagation
### Is your feature request related to a problem? Please describe.

As mentioned in https://github.com/cython/cython/pull/4670#issuecomment-1279918128 introducing default exception propagation in #4670 can introduce compatibility issues for cython users.


### Describe the solution you'd like.

New command line option should be provided enabling legacy noexcept propagation as default:

```
cython -X noexcept ...
```

### Describe alternatives you've considered.

_No response_

### Additional context

_No response_


Discussion:
While, on the one hand, this would probably keep users from fixing their code and benefiting from the new, safer behaviour, on the other hand, forcing users to make the complete switch before they can migrate to Cython 3.0 isn't going to help anyone. Disabling the new behaviour does not degrade existing code bases in any way, it just keeps them as buggy as they are.

This new option does not reduce the burden for the migration, though. In order to migrate even just one single function, users will have to remove the option again, and then adapt the entire module, as well as potentially starting with other modules that call into the one at hand. So, the risk is high that users just add the option and leave it there, lacking the time to migrate.

It would be nice if we could continue to issue the warnings if the option is provided. In any case, the option would probably have to be separate from the `noexcept` function modifier, so this should de detectable.

So, it feels difficult to actually help users with the migration by providing some kind of intermediate steps. It seems the migration is either all or nothing. And this option keeps it at nothing. I understand the need, but it would be great to have something that actually helps migrating instead.
> It would be nice if we could continue to issue the warnings if the option is provided. In any case, the option would probably have to be separate from the `noexcept` function modifier, so this should de detectable.

That sounds feasible though, as we have the information about the CLI flag completely separately from the parsing?

That way it wouldn't reset the migration to "nothing", it would just be one more warning users will eventually need to heed before they _really_ get broken by the removal.
As a maintainer of a library using Cython, I think it is sensible to have a temporary `-X` option to enable the legacy `noexcept` propagation for a few release only and instructions to let users adapt their code base to use the new `noexcept` propagation.
> This new option does not reduce the burden for the migration, though. In order to migrate even just one single function, users will have to remove the option again, and then adapt the entire module, as well as potentially starting with other modules that call into the one at hand. So, the risk is high that users just add the option and leave it there, lacking the time to migrate.

Hmm as long as I understand this option can be enabled on per-file bases. So users can put it to all files and gradually remove it. But I am not sure if this is good approach for users or not...
EDIT: If there will be agreement between Cython maintainers that we won't go this direction, I am not against closing this issue without the fix.
The main advantage of the per-file basis is that I think anything else would be hard to do with the current implementation.

I don't think it's terrible for users. I'm sure a lot will just turn it on for their existing code and forget about it. But it does at least provide a solution to not break everything immediately
cc @shwina (in case you have thoughts on this)

Resolving patch:
diff --git a/Cython/Compiler/Main.py b/Cython/Compiler/Main.py
--- a/Cython/Compiler/Main.py
+++ b/Cython/Compiler/Main.py
@@ -91,6 +91,8 @@ def __init__(self, include_directories, compiler_directives, cpp=False,
         if language_level is not None:
             self.set_language_level(language_level)
 
+        self.legacy_implicit_noexcept = self.compiler_directives.get('legacy_implicit_noexcept', False)
+
         self.gdb_debug_outputwriter = None
 
     @classmethod
diff --git a/Cython/Compiler/Options.py b/Cython/Compiler/Options.py
--- a/Cython/Compiler/Options.py
+++ b/Cython/Compiler/Options.py
@@ -218,6 +218,7 @@ def copy_inherited_directives(outer_directives, **new_directives):
     'np_pythran': False,
     'fast_gil': False,
     'cpp_locals': False,  # uses std::optional for C++ locals, so that they work more like Python locals
+    'legacy_implicit_noexcept': False,
 
     # set __file__ and/or __path__ to known source/target path at import time (instead of not having them available)
     'set_initial_path' : None,  # SOURCEFILE or "/full/path/to/module"
@@ -385,6 +386,7 @@ class DEFER_ANALYSIS_OF_ARGUMENTS:
     'total_ordering': ('cclass', ),
     'dataclasses.dataclass' : ('class', 'cclass',),
     'cpp_locals': ('module', 'function', 'cclass'),  # I don't think they make sense in a with_statement
+    'legacy_implicit_noexcept': ('module', ),
 }
 
 
@@ -776,5 +778,6 @@ def to_fingerprint(item):
     build_dir=None,
     cache=None,
     create_extension=None,
-    np_pythran=False
+    np_pythran=False,
+    legacy_implicit_noexcept=None,
 )
diff --git a/Cython/Compiler/Parsing.py b/Cython/Compiler/Parsing.py
--- a/Cython/Compiler/Parsing.py
+++ b/Cython/Compiler/Parsing.py
@@ -3120,6 +3120,9 @@ def p_exception_value_clause(s, ctx):
                 exc_check = False
             # exc_val can be non-None even if exc_check is False, c.f. "except -1"
             exc_val = p_test(s)
+    if not exc_clause and ctx.visibility  != 'extern' and s.context.legacy_implicit_noexcept:
+        exc_check = False
+        warning(s.position(), "Implicit noexcept declaration is deprecated. Function declaration should contain 'noexcept' keyword.", level=2)
     return exc_val, exc_check, exc_clause
 
 c_arg_list_terminators = cython.declare(frozenset, frozenset((
@@ -3888,6 +3891,9 @@ def p_compiler_directive_comments(s):
             if 'language_level' in new_directives:
                 # Make sure we apply the language level already to the first token that follows the comments.
                 s.context.set_language_level(new_directives['language_level'])
+            if 'legacy_implicit_noexcept' in new_directives:
+                s.context.legacy_implicit_noexcept = new_directives['legacy_implicit_noexcept']
+
 
             result.update(new_directives)
 


Test patch:
diff --git a/tests/run/legacy_implicit_noexcept.pyx b/tests/run/legacy_implicit_noexcept.pyx
new file mode 100644
--- /dev/null
+++ b/tests/run/legacy_implicit_noexcept.pyx
@@ -0,0 +1,143 @@
+# cython: legacy_implicit_noexcept=True
+# mode: run
+# tag: warnings
+import sys
+import functools
+import cython
+try:
+    from StringIO import StringIO
+except ImportError:
+    from io import StringIO
+
+cdef int func_implicit(int a, int b):
+    raise RuntimeError
+
+cdef int func_noexcept(int a, int b) noexcept:
+    raise RuntimeError
+
+cdef int func_star(int a, int b) except *:
+    raise RuntimeError
+
+cdef int func_value(int a, int b) except -1:
+    raise RuntimeError
+
+cdef func_return_obj_implicit(int a, int b):
+    raise RuntimeError
+
+cdef int(*ptr_func_implicit)(int, int)
+ptr_func_implicit = func_implicit
+
+cdef int(*ptr_func_noexcept)(int, int) noexcept
+ptr_func_noexcept = func_noexcept
+
+@cython.cfunc
+def func_pure_implicit() -> cython.int:
+    raise RuntimeError
+
+@cython.excetval(check=False)
+@cython.cfunc
+def func_pure_noexcept() -> cython.int:
+    raise RuntimeError
+
+def return_stderr(func):
+    @functools.wraps(func)
+    def testfunc():
+        old_stderr = sys.stderr
+        stderr = sys.stderr = StringIO()
+        try:
+            func()
+        finally:
+            sys.stderr = old_stderr
+        return stderr.getvalue().strip()
+
+    return testfunc
+
+@return_stderr
+def test_noexcept():
+    """
+    >>> print(test_noexcept())  # doctest: +ELLIPSIS
+    RuntimeError
+    Exception...ignored...
+    """
+    func_noexcept(3, 5)
+
+@return_stderr
+def test_ptr_noexcept():
+    """
+    >>> print(test_ptr_noexcept())  # doctest: +ELLIPSIS
+    RuntimeError
+    Exception...ignored...
+    """
+    ptr_func_noexcept(3, 5)
+
+@return_stderr
+def test_implicit():
+    """
+    >>> print(test_implicit())  # doctest: +ELLIPSIS
+    RuntimeError
+    Exception...ignored...
+    """
+    func_implicit(1, 2)
+
+@return_stderr
+def test_ptr_implicit():
+    """
+    >>> print(test_ptr_implicit())  # doctest: +ELLIPSIS
+    RuntimeError
+    Exception...ignored...
+    """
+    ptr_func_implicit(1, 2)
+
+def test_star():
+    """
+    >>> test_star()
+    Traceback (most recent call last):
+    ...
+    RuntimeError
+    """
+    func_star(1, 2)
+
+def test_value():
+    """
+    >>> test_value()
+    Traceback (most recent call last):
+    ...
+    RuntimeError
+    """
+    func_value(1, 2)
+
+
+def test_return_obj_implicit():
+    """
+    >>> test_return_obj_implicit()
+    Traceback (most recent call last):
+    ...
+    RuntimeError
+    """
+    func_return_obj_implicit(1, 2)
+
+def test_pure_implicit():
+    """
+    >>> test_pure_implicit()
+    Traceback (most recent call last):
+    ...
+    RuntimeError
+    """
+    func_pure_implicit()
+
+def test_pure_noexcept():
+    """
+    >>> test_pure_noexcept()
+    Traceback (most recent call last):
+    ...
+    RuntimeError
+    """
+    func_pure_noexcept()
+
+_WARNINGS = """
+12:5: Unraisable exception in function 'legacy_implicit_noexcept.func_implicit'.
+12:36: Implicit noexcept declaration is deprecated. Function declaration should contain 'noexcept' keyword.
+15:5: Unraisable exception in function 'legacy_implicit_noexcept.func_noexcept'.
+24:43: Implicit noexcept declaration is deprecated. Function declaration should contain 'noexcept' keyword.
+27:38: Implicit noexcept declaration is deprecated. Function declaration should contain 'noexcept' keyword.
+"""
diff --git a/tests/run/legacy_implicit_noexcept_build.srctree b/tests/run/legacy_implicit_noexcept_build.srctree
new file mode 100644
--- /dev/null
+++ b/tests/run/legacy_implicit_noexcept_build.srctree
@@ -0,0 +1,33 @@
+PYTHON setup.py build_ext --inplace
+PYTHON -c "import bar"
+
+######## setup.py ########
+
+from Cython.Build.Dependencies import cythonize
+from distutils.core import setup
+
+setup(
+  ext_modules = cythonize("*.pyx", compiler_directives={'legacy_implicit_noexcept': True}),
+)
+
+
+######## bar.pyx ########
+
+cdef int func_noexcept() noexcept:
+    raise RuntimeError()
+
+cdef int func_implicit():
+    raise RuntimeError()
+
+cdef int func_return_value() except -1:
+    raise RuntimeError()
+
+func_noexcept()
+func_implicit()
+
+try:
+    func_return_value()
+except RuntimeError:
+    pass
+else:
+    assert False, 'Exception not raised'