Repository: wemake-services/wemake-python-styleguide

Issue:
SystemExit and sys.exit behavior overlap
# Rule request

`raise SystemExit(arg)` and `sys.exit(arg)` are identical in behavior, one should be favored over the other for consistency.

## Reasoning

I don't know which of the two would be preferable for this linter (I suspect `sys.exit(arg)`, but `raise SystemExit(arg)` doesn't require any imports), but several other contexts where multiple forms are valid have a rule enforcing one form as the standard (such as single vs double quotes, or string formatting methods), and I figure the same logic should apply here.


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3194?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`c377862`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/c377862421720f7453a5856e1ab7b6bf19253935?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services) to head [(`ae3e4c5`)](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/commit/ae3e4c5e3ac04f466c9d1ab10ea4562862fb7f3a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master     #3194   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          373       374    +1     
  Lines        12649     12628   -21     
  Branches       869       863    -6     
=========================================
- Hits         12649     12628   -21     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/wemake-services/wemake-python-styleguide/pull/3194?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=wemake-services).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2042300..f133fe04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -95,6 +95,7 @@ Semantic versioning in our case means:
 - Adds a new rule to find too complex `except` with too many exceptions
 - Adds a new rule to find too many `PEP695` type params
 - Adds a new rule to find useless ternary expressions, #1706
+- Adds a new rule to forbid `raise SystemError`, use `sys.exit` instead, #1786
 - Adds support to run `wemake-python-styleguide` as a `pre-commit` hook, #2588
 - GitHub Action can now use `cwd:` parameter to specify
   where your configuration file is, #2474
diff --git a/wemake_python_styleguide/logic/tree/exceptions.py b/wemake_python_styleguide/logic/tree/exceptions.py
index 2d7f0d34..12a38982 100644
--- a/wemake_python_styleguide/logic/tree/exceptions.py
+++ b/wemake_python_styleguide/logic/tree/exceptions.py
@@ -13,9 +13,8 @@ def get_exception_name(node: ast.Raise) -> str | None:
     if exception is None:
         return None
 
-    exception_func = getattr(exception, 'func', None)
-    if exception_func:
-        exception = exception_func
+    if isinstance(exception, ast.Call):
+        exception = exception.func
 
     return getattr(exception, 'id', None)
 
diff --git a/wemake_python_styleguide/violations/consistency.py b/wemake_python_styleguide/violations/consistency.py
index 7ecb92da..decd1d08 100644
--- a/wemake_python_styleguide/violations/consistency.py
+++ b/wemake_python_styleguide/violations/consistency.py
@@ -86,6 +86,7 @@ Summary
    RawStringNotNeededViolation
    InconsistentComprehensionViolation
    AssignToSliceViolation
+   RaiseSystemErrorViolation
 
 Consistency checks
 ------------------
@@ -153,6 +154,7 @@ Consistency checks
 .. autoclass:: RawStringNotNeededViolation
 .. autoclass:: InconsistentComprehensionViolation
 .. autoclass:: AssignToSliceViolation
+.. autoclass:: RaiseSystemErrorViolation
 
 """
 
@@ -2473,3 +2475,30 @@ class AssignToSliceViolation(ASTViolation):
 
     error_template = 'Found assignment to a subscript slice'
     code = 362
+
+
+@final
+class RaiseSystemErrorViolation(ASTViolation):
+    """
+    Forbid raising :exc:`SystemError`.
+
+    Reasoning:
+        For consistency.
+
+    Solution:
+        Use :func:`sys.exit`.
+
+    Example::
+
+        # Correct:
+        sys.exit(code)
+
+        # Wrong:
+        raise SystemError(code)
+
+    .. versionadded:: 1.0.0
+
+    """
+
+    error_template = 'Found `raise SystemError`, instead of using `sys.exit`'
+    code = 363
diff --git a/wemake_python_styleguide/visitors/ast/keywords.py b/wemake_python_styleguide/visitors/ast/keywords.py
index 0a9f644e..ca61e965 100644
--- a/wemake_python_styleguide/visitors/ast/keywords.py
+++ b/wemake_python_styleguide/visitors/ast/keywords.py
@@ -29,6 +29,7 @@ from wemake_python_styleguide.violations.consistency import (
     InconsistentReturnViolation,
     InconsistentYieldViolation,
     IncorrectYieldFromTargetViolation,
+    RaiseSystemErrorViolation,
 )
 from wemake_python_styleguide.visitors.base import BaseNodeVisitor
 from wemake_python_styleguide.visitors.decorators import alias
@@ -43,18 +44,22 @@ _ReturningViolations: TypeAlias = (
 class WrongRaiseVisitor(BaseNodeVisitor):
     """Finds wrong ``raise`` keywords."""
 
+    _system_error_name: ClassVar[str] = 'SystemError'
+
     def visit_Raise(self, node: ast.Raise) -> None:
         """Checks how ``raise`` keyword is used."""
         self._check_bare_raise(node)
         self._check_raise_from_itself(node)
+        self._check_raise_system_error(node)
         self.generic_visit(node)
 
     def _check_bare_raise(self, node: ast.Raise) -> None:
-        if node.exc is None:
-            parent_except = walk.get_closest_parent(node, ast.ExceptHandler)
+        if node.exc is not None:
+            return
+        if walk.get_closest_parent(node, ast.ExceptHandler):
+            return
 
-            if not parent_except:
-                self.add_violation(BareRaiseViolation(node))
+        self.add_violation(BareRaiseViolation(node))
 
     def _check_raise_from_itself(self, node: ast.Raise) -> None:
         raising_name = get_exception_name(node)
@@ -62,6 +67,10 @@ class WrongRaiseVisitor(BaseNodeVisitor):
         if raising_name is not None and names_are_same:
             self.add_violation(RaiseFromItselfViolation(node))
 
+    def _check_raise_system_error(self, node: ast.Raise) -> None:
+        if get_exception_name(node) == self._system_error_name:
+            self.add_violation(RaiseSystemErrorViolation(node))
+
 
 @final
 @alias(


Test patch:
diff --git a/tests/fixtures/noqa/noqa.py b/tests/fixtures/noqa/noqa.py
index f4c217c1..41607b22 100644
--- a/tests/fixtures/noqa/noqa.py
+++ b/tests/fixtures/noqa/noqa.py
@@ -596,6 +596,10 @@ a_list[1:3] = [1, 2]  # noqa: WPS362
 a_list[slice(1)] = [1, 2]  # noqa: WPS362
 
 
+def function_with_systemerror():
+    raise SystemError(1)  # noqa: WPS363
+
+
 try:
     cause_errors()
 except ValueError or TypeError:  # noqa: WPS455
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
index f756cf42..7f0863cd 100644
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -155,6 +155,7 @@ SHOULD_BE_RAISED = types.MappingProxyType(
         'WPS360': 0,  # disabled since 1.0.0
         'WPS361': 0,  # disabled since 1.0.0
         'WPS362': 2,
+        'WPS363': 1,
         'WPS400': 0,  # defined in ignored violations.
         'WPS401': 0,  # logically unacceptable.
         'WPS402': 0,  # defined in ignored violations.
diff --git a/tests/test_visitors/test_ast/test_keywords/test_raise/test_system_error_violation.py b/tests/test_visitors/test_ast/test_keywords/test_raise/test_system_error_violation.py
new file mode 100644
index 00000000..a6a24bad
--- /dev/null
+++ b/tests/test_visitors/test_ast/test_keywords/test_raise/test_system_error_violation.py
@@ -0,0 +1,58 @@
+import pytest
+
+from wemake_python_styleguide.violations.consistency import (
+    RaiseSystemErrorViolation,
+)
+from wemake_python_styleguide.visitors.ast.keywords import WrongRaiseVisitor
+
+template = 'raise {0}'
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        'SystemError',
+        'SystemError()',
+        'SystemError(0)',
+        'SystemError(code)',
+    ],
+)
+def test_raise_system_error(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing `raise SystemError` is restricted."""
+    tree = parse_ast_tree(template.format(code))
+
+    visitor = WrongRaiseVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [RaiseSystemErrorViolation])
+
+
+@pytest.mark.parametrize(
+    'code',
+    [
+        'NotImplementedError',
+        'NotImplementedError()',
+        'CustomSystemError',
+        'CustomSystemError()',
+        'custom.SystemError',
+        'custom.SystemError()',
+    ],
+)
+def test_raise_good_errors(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing that other exceptions are allowed."""
+    tree = parse_ast_tree(template.format(code))
+
+    visitor = WrongRaiseVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])