errgrade 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
errgrade-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mgkvibez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: errgrade
3
+ Version: 0.1.0
4
+ Summary: Grade the error messages your code raises: what broke, where, and what to do next.
5
+ Author: mgkvibez
6
+ License: MIT
7
+ Keywords: linter,errors,dx,code-review,developer-tools,static-analysis
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # errgrade
22
+
23
+ Grade the error messages your code raises: does it say **what** broke, **where**, and **what to do next**.
24
+
25
+ ```
26
+ $ errgrade examples/before.py
27
+ errgrade: 4 finding(s) across 6 message(s) in 1 file(s), worst high
28
+ 1 of 6 graded message(s) fall below C
29
+
30
+ [HIGH] examples/before.py:6 (vague-error)
31
+ message: 'failed'
32
+ why: the message does not say what went wrong
33
+ missing: what, where, next
34
+ grade: D (0/100)
35
+
36
+ [HIGH] examples/before.py:8 (literal-placeholder)
37
+ message: 'missing {path}'
38
+ why: braces are shown to the reader verbatim: this string is never formatted
39
+ missing: next
40
+ grade: B (75/100)
41
+
42
+ [LOW] examples/before.py:10 (no-next-step)
43
+ message: 'config file is empty'
44
+ why: nothing tells the reader what to do next
45
+ missing: next
46
+ grade: B (75/100)
47
+ ```
48
+
49
+ Note what it does **not** flag: `amount must be positive, got {amount}` and
50
+ `currency must be one of NGN, USD` pass, because they answer all three questions.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pipx install errgrade
56
+ ```
57
+
58
+ Python 3.9+, **zero runtime dependencies**. The analysis is a standard-library AST pass.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ # grade the current directory (tests excluded: their messages are not user-facing)
64
+ errgrade
65
+
66
+ # one file or directory, and see the worst offenders first
67
+ errgrade src --top 5
68
+
69
+ # machine-readable, for a bot or a CI annotation step
70
+ errgrade --json
71
+
72
+ # fail CI only on the messages a user would actually see going wrong
73
+ errgrade src --fail-on high
74
+ ```
75
+
76
+ Exit codes: `0` nothing at or above the threshold, `1` findings at or above it,
77
+ `2` usage error. A linter that exits 1 for its own mistakes makes CI lie, which is why
78
+ those are separate.
79
+
80
+ `--fail-on` defaults to `medium`. `low` findings never fail a run unless you ask.
81
+
82
+ ## Why it exists
83
+
84
+ Error messages are the only part of your code that runs at the worst moment of someone's
85
+ day. They are also the one thing nobody reviews: a pull request diff shows a new
86
+ `raise`, and reviewers look at the condition, not the sentence.
87
+
88
+ The cost is invisible and real. `ValueError("failed")` reaches a user as a dead end, and
89
+ reaches you as a support ticket that says "it doesn't work". The difference between that
90
+ and `amount must be positive, got -5` is four words and one value.
91
+
92
+ ## How the grading works
93
+
94
+ Every message is scored on three axes, because those are the three questions a stuck
95
+ person asks:
96
+
97
+ | Axis | Weight | Satisfied by |
98
+ |---|---|---|
99
+ | what | 40 | not a known filler phrase, and at least two words |
100
+ | where | 35 | an interpolated value, a named subject ("tenant is archived"), a location noun, or a list of valid values |
101
+ | next | 25 | an imperative ("set", "pass", "check", "give"), a constraint ("must be", "expected"), or the allowed values |
102
+
103
+ 100 becomes `A`, 85+ `A`, 70+ `B`, 50+ `C`, below that `D`. A message with no
104
+ interpolated value and no named subject cannot tell one failure from another, which is
105
+ what pushes it below `C`.
106
+
107
+ The values come from the syntax tree, not the text: an f-string, `.format()` or `%`
108
+ counts as interpolation because a value genuinely flows in. `"{name}"` written as a plain
109
+ string counts as a bug, because the reader sees the braces.
110
+
111
+ ## Rules
112
+
113
+ | Severity | Rule | What it means |
114
+ |---|---|---|
115
+ | high | `vague-error` | the message does not say what went wrong (`"failed"`, `"invalid input"`) |
116
+ | high | `literal-placeholder` | braces reach the reader verbatim because the string is never formatted |
117
+ | medium | `no-context` | no value and no subject: the reader cannot tell which case this is |
118
+ | medium | `bare-exception` | `raise ValueError()` with no message at all |
119
+ | low | `no-next-step` | nothing tells the reader what to do next |
120
+
121
+ One primary finding per message: a vague message is not also reported for context and
122
+ next steps, because stacking three findings on one word is noise, not help.
123
+
124
+ ## Suppressing a message
125
+
126
+ Some messages are deliberately terse. Say so on the line:
127
+
128
+ ```python
129
+ raise ValueError("failed") # errgrade: ignore
130
+ ```
131
+
132
+ Only that line is suppressed. There is no config file to fall out of sync.
133
+
134
+ ## What this deliberately does not do
135
+
136
+ - **Python only, for now.** The grading itself takes a string and does not care where it
137
+ came from; only the AST extraction is Python-specific.
138
+ - **It grades literal messages.** `raise ValueError(msg)` is skipped, because grading a
139
+ variable would mean inventing its contents.
140
+ - **It is a heuristic, not a review.** A high grade means the message answers the three
141
+ questions as far as a static tool can tell, not that it is good writing. "No issues
142
+ found" means no known weak pattern matched, and is not a guarantee.
143
+ - **It does not touch logging.** Log lines are for people with the code open; `raise`
144
+ strings are what users and callers read.
145
+ - **It does not rewrite your messages.** It tells you which ones are a dead end and why.
146
+
147
+ ## Tests
148
+
149
+ ```bash
150
+ python -m pytest -q # 52 tests
151
+ bash scripts/verify.sh # the release gate, 6 checks
152
+ ```
153
+
154
+ Contributions: the rules live in `errgrade/analyze.py`, the scoring in
155
+ `errgrade/grade.py`, and every rule has a test in `tests/test_analyze.py`. A new
156
+ heuristic needs a case it must flag and a case it must not.
157
+
158
+ ## License
159
+
160
+ MIT.
@@ -0,0 +1,140 @@
1
+ # errgrade
2
+
3
+ Grade the error messages your code raises: does it say **what** broke, **where**, and **what to do next**.
4
+
5
+ ```
6
+ $ errgrade examples/before.py
7
+ errgrade: 4 finding(s) across 6 message(s) in 1 file(s), worst high
8
+ 1 of 6 graded message(s) fall below C
9
+
10
+ [HIGH] examples/before.py:6 (vague-error)
11
+ message: 'failed'
12
+ why: the message does not say what went wrong
13
+ missing: what, where, next
14
+ grade: D (0/100)
15
+
16
+ [HIGH] examples/before.py:8 (literal-placeholder)
17
+ message: 'missing {path}'
18
+ why: braces are shown to the reader verbatim: this string is never formatted
19
+ missing: next
20
+ grade: B (75/100)
21
+
22
+ [LOW] examples/before.py:10 (no-next-step)
23
+ message: 'config file is empty'
24
+ why: nothing tells the reader what to do next
25
+ missing: next
26
+ grade: B (75/100)
27
+ ```
28
+
29
+ Note what it does **not** flag: `amount must be positive, got {amount}` and
30
+ `currency must be one of NGN, USD` pass, because they answer all three questions.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install errgrade
36
+ ```
37
+
38
+ Python 3.9+, **zero runtime dependencies**. The analysis is a standard-library AST pass.
39
+
40
+ ## Usage
41
+
42
+ ```bash
43
+ # grade the current directory (tests excluded: their messages are not user-facing)
44
+ errgrade
45
+
46
+ # one file or directory, and see the worst offenders first
47
+ errgrade src --top 5
48
+
49
+ # machine-readable, for a bot or a CI annotation step
50
+ errgrade --json
51
+
52
+ # fail CI only on the messages a user would actually see going wrong
53
+ errgrade src --fail-on high
54
+ ```
55
+
56
+ Exit codes: `0` nothing at or above the threshold, `1` findings at or above it,
57
+ `2` usage error. A linter that exits 1 for its own mistakes makes CI lie, which is why
58
+ those are separate.
59
+
60
+ `--fail-on` defaults to `medium`. `low` findings never fail a run unless you ask.
61
+
62
+ ## Why it exists
63
+
64
+ Error messages are the only part of your code that runs at the worst moment of someone's
65
+ day. They are also the one thing nobody reviews: a pull request diff shows a new
66
+ `raise`, and reviewers look at the condition, not the sentence.
67
+
68
+ The cost is invisible and real. `ValueError("failed")` reaches a user as a dead end, and
69
+ reaches you as a support ticket that says "it doesn't work". The difference between that
70
+ and `amount must be positive, got -5` is four words and one value.
71
+
72
+ ## How the grading works
73
+
74
+ Every message is scored on three axes, because those are the three questions a stuck
75
+ person asks:
76
+
77
+ | Axis | Weight | Satisfied by |
78
+ |---|---|---|
79
+ | what | 40 | not a known filler phrase, and at least two words |
80
+ | where | 35 | an interpolated value, a named subject ("tenant is archived"), a location noun, or a list of valid values |
81
+ | next | 25 | an imperative ("set", "pass", "check", "give"), a constraint ("must be", "expected"), or the allowed values |
82
+
83
+ 100 becomes `A`, 85+ `A`, 70+ `B`, 50+ `C`, below that `D`. A message with no
84
+ interpolated value and no named subject cannot tell one failure from another, which is
85
+ what pushes it below `C`.
86
+
87
+ The values come from the syntax tree, not the text: an f-string, `.format()` or `%`
88
+ counts as interpolation because a value genuinely flows in. `"{name}"` written as a plain
89
+ string counts as a bug, because the reader sees the braces.
90
+
91
+ ## Rules
92
+
93
+ | Severity | Rule | What it means |
94
+ |---|---|---|
95
+ | high | `vague-error` | the message does not say what went wrong (`"failed"`, `"invalid input"`) |
96
+ | high | `literal-placeholder` | braces reach the reader verbatim because the string is never formatted |
97
+ | medium | `no-context` | no value and no subject: the reader cannot tell which case this is |
98
+ | medium | `bare-exception` | `raise ValueError()` with no message at all |
99
+ | low | `no-next-step` | nothing tells the reader what to do next |
100
+
101
+ One primary finding per message: a vague message is not also reported for context and
102
+ next steps, because stacking three findings on one word is noise, not help.
103
+
104
+ ## Suppressing a message
105
+
106
+ Some messages are deliberately terse. Say so on the line:
107
+
108
+ ```python
109
+ raise ValueError("failed") # errgrade: ignore
110
+ ```
111
+
112
+ Only that line is suppressed. There is no config file to fall out of sync.
113
+
114
+ ## What this deliberately does not do
115
+
116
+ - **Python only, for now.** The grading itself takes a string and does not care where it
117
+ came from; only the AST extraction is Python-specific.
118
+ - **It grades literal messages.** `raise ValueError(msg)` is skipped, because grading a
119
+ variable would mean inventing its contents.
120
+ - **It is a heuristic, not a review.** A high grade means the message answers the three
121
+ questions as far as a static tool can tell, not that it is good writing. "No issues
122
+ found" means no known weak pattern matched, and is not a guarantee.
123
+ - **It does not touch logging.** Log lines are for people with the code open; `raise`
124
+ strings are what users and callers read.
125
+ - **It does not rewrite your messages.** It tells you which ones are a dead end and why.
126
+
127
+ ## Tests
128
+
129
+ ```bash
130
+ python -m pytest -q # 52 tests
131
+ bash scripts/verify.sh # the release gate, 6 checks
132
+ ```
133
+
134
+ Contributions: the rules live in `errgrade/analyze.py`, the scoring in
135
+ `errgrade/grade.py`, and every rule has a test in `tests/test_analyze.py`. A new
136
+ heuristic needs a case it must flag and a case it must not.
137
+
138
+ ## License
139
+
140
+ MIT.
@@ -0,0 +1,3 @@
1
+ """errgrade: grade the error messages your code raises."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,269 @@
1
+ """Find the messages a Python program would actually show a person.
2
+
3
+ Only ``raise`` sites count. A string handed to ``logging.error`` is for operators who
4
+ have the code open; a string in ``raise`` is what a user or a caller reads when
5
+ something is already going wrong, and that is the one worth grading.
6
+
7
+ Extraction is AST-based on purpose. Grepping for ``raise`` finds it inside comments,
8
+ docstrings and string literals, which is how most "quick linter" scripts end up with
9
+ noise instead of findings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import List, Optional, Sequence, Tuple
18
+
19
+ from .grade import PLACEHOLDER_RE, Grade, grade_message
20
+
21
+ SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
22
+
23
+ #: Frameworks put the human-facing text in a keyword, not a positional argument:
24
+ #: ``HTTPException(status_code=401, detail="invalid signature")``.
25
+ MESSAGE_KWARGS = ("detail", "message", "msg", "reason")
26
+
27
+ #: Inline suppression, so a deliberate message does not need a config file.
28
+ PRAGMA = "errgrade: ignore"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Finding:
33
+ """One suspicious message, located well enough to fix without searching."""
34
+
35
+ path: str
36
+ line: int
37
+ rule: str
38
+ severity: str
39
+ message: str
40
+ detail: str
41
+ grade: Grade
42
+
43
+ def to_dict(self) -> dict:
44
+ return {
45
+ "path": self.path,
46
+ "line": self.line,
47
+ "rule": self.rule,
48
+ "severity": self.severity,
49
+ "message": self.message,
50
+ "detail": self.detail,
51
+ **self.grade.to_dict(),
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class Report:
57
+ findings: List[Finding] = field(default_factory=list)
58
+ graded: int = 0
59
+ files: int = 0
60
+ below_c: int = 0
61
+
62
+ def sorted_findings(self) -> List[Finding]:
63
+ return sorted(self.findings, key=lambda f: (SEVERITY_ORDER[f.severity], f.path, f.line))
64
+
65
+
66
+ def _call_name(node: ast.AST) -> str:
67
+ if isinstance(node, ast.Call):
68
+ func = node.func
69
+ if isinstance(func, ast.Name):
70
+ return func.id
71
+ if isinstance(func, ast.Attribute):
72
+ return func.attr
73
+ if isinstance(node, ast.Name):
74
+ return node.id
75
+ return ""
76
+
77
+
78
+ def _string_parts(node: ast.AST) -> Optional[Tuple[str, bool]]:
79
+ """Return (text, interpolated) for a message expression, or None if unreadable.
80
+
81
+ ``interpolated`` marks a value flowing into the text. That is the only honest
82
+ signal that a message says *where* the failure happened.
83
+ """
84
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
85
+ return node.value, False
86
+ if isinstance(node, ast.JoinedStr):
87
+ text = "".join(
88
+ part.value if isinstance(part, ast.Constant) and isinstance(part.value, str) else "<value>"
89
+ for part in node.values
90
+ )
91
+ has_value = any(isinstance(part, ast.FormattedValue) for part in node.values)
92
+ return text, has_value
93
+ if isinstance(node, ast.BinOp):
94
+ # "x" % name or "x" + name
95
+ if isinstance(node.op, (ast.Mod, ast.Add)):
96
+ left = _string_parts(node.left)
97
+ if left:
98
+ text, _ = left
99
+ return text, True
100
+ right = _string_parts(node.right)
101
+ if right and isinstance(node.op, ast.Add):
102
+ text, _ = right
103
+ return text, True
104
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
105
+ # "x {}".format(name) / f-string style helpers
106
+ if node.func.attr == "format":
107
+ base = _string_parts(node.func.value)
108
+ if base:
109
+ text, _ = base
110
+ # "{}".format(x) interpolates; "{a}" without args does not.
111
+ return text, bool(node.args or node.keywords)
112
+ return None
113
+
114
+
115
+ def _message_node(exc: ast.AST) -> Optional[ast.AST]:
116
+ """The expression holding the message, wherever this framework keeps it."""
117
+ if not isinstance(exc, ast.Call):
118
+ return None
119
+ if exc.args:
120
+ return exc.args[0]
121
+ for kw in exc.keywords:
122
+ if kw.arg in MESSAGE_KWARGS:
123
+ return kw.value
124
+ return None
125
+
126
+
127
+ def _docstring_lines(tree: ast.AST) -> set:
128
+ """Lines occupied by docstrings, so a message quoted in prose is not graded."""
129
+ lines = set()
130
+ for node in ast.walk(tree):
131
+ if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
132
+ body = getattr(node, "body", None)
133
+ if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
134
+ and isinstance(body[0].value.value, str):
135
+ first, last = body[0].lineno, getattr(body[0], "end_lineno", body[0].lineno)
136
+ lines.update(range(first, last + 1))
137
+ return lines
138
+
139
+
140
+ def analyze_source(source: str, path: str = "<src>") -> List[Finding]:
141
+ """Every finding in one file."""
142
+ try:
143
+ tree = ast.parse(source)
144
+ except SyntaxError:
145
+ return []
146
+
147
+ ignored = {i + 1 for i, line in enumerate(source.splitlines()) if PRAGMA in line}
148
+ doc_lines = _docstring_lines(tree)
149
+ findings: List[Finding] = []
150
+
151
+ for node in ast.walk(tree):
152
+ if not isinstance(node, ast.Raise) or node.exc is None:
153
+ continue
154
+ exc = node.exc
155
+ name = _call_name(exc)
156
+ if not name:
157
+ continue
158
+ line = node.lineno
159
+ if line in ignored:
160
+ continue
161
+
162
+ node_for_message = _message_node(exc)
163
+ if node_for_message is None:
164
+ # A raise with framing keywords but no readable text (e.g. status_code=500)
165
+ # cannot be graded honestly, so it is skipped rather than guessed at.
166
+ if isinstance(exc, ast.Call) and (exc.args or exc.keywords):
167
+ continue
168
+ findings.append(Finding(
169
+ path, line, "bare-exception", "medium", name + "()",
170
+ "raised with no message, so the caller learns nothing beyond the type",
171
+ grade_message(""),
172
+ ))
173
+ continue
174
+
175
+ parts = _string_parts(node_for_message)
176
+ if parts is None:
177
+ continue
178
+ text, interpolated = parts
179
+
180
+ if not text.strip():
181
+ continue
182
+ grade = grade_message(text, interpolated=interpolated)
183
+
184
+ # A brace placeholder in a string that is not interpolated reaches the user
185
+ # literally, which is a bug rather than a style problem.
186
+ if not interpolated and PLACEHOLDER_RE.search(text):
187
+ findings.append(Finding(
188
+ path, line, "literal-placeholder", "high", text,
189
+ "braces are shown to the reader verbatim: this string is never formatted",
190
+ grade,
191
+ ))
192
+ continue
193
+
194
+ if not grade.what:
195
+ findings.append(Finding(
196
+ path, line, "vague-error", "high", text,
197
+ "the message does not say what went wrong",
198
+ grade,
199
+ ))
200
+ continue # one primary verdict per message; the low rules below add noise
201
+ elif not grade.where:
202
+ findings.append(Finding(
203
+ path, line, "no-context", "medium", text,
204
+ "no value or location: the reader cannot tell which case this is",
205
+ grade,
206
+ ))
207
+ continue
208
+
209
+ if not grade.next:
210
+ findings.append(Finding(
211
+ path, line, "no-next-step", "low", text,
212
+ "nothing tells the reader what to do next",
213
+ grade,
214
+ ))
215
+
216
+ return findings
217
+
218
+
219
+ def analyze_files(files: Sequence[Path], *, include_tests: bool = False) -> Report:
220
+ """Analyse a list of paths, expanding directories."""
221
+ report = Report()
222
+ paths: List[Path] = []
223
+ for p in files:
224
+ if p.is_dir():
225
+ paths.extend(sorted(p.rglob("*.py")))
226
+ elif p.suffix == ".py":
227
+ paths.append(p)
228
+
229
+ for path in paths:
230
+ if _looks_like_artifact(path):
231
+ continue
232
+ if not include_tests and _looks_like_test(path):
233
+ continue
234
+ try:
235
+ source = path.read_text(encoding="utf-8")
236
+ except (OSError, UnicodeDecodeError):
237
+ continue
238
+ report.files += 1
239
+ file_findings = analyze_source(source, str(path))
240
+ report.findings.extend(file_findings)
241
+ report.graded += _count_raises(source)
242
+ report.below_c += sum(1 for f in file_findings if f.grade.letter in ("C", "D"))
243
+ return report
244
+
245
+
246
+ SKIP_DIRS = frozenset({"build", "dist", "node_modules", ".git", ".venv", "venv",
247
+ "site-packages", "__pycache__", ".tox", ".mypy_cache",
248
+ "egg-info", ".eggs"})
249
+
250
+
251
+ def _looks_like_artifact(path: Path) -> bool:
252
+ """Build outputs and vendored trees are not your code to fix."""
253
+ return any(p.lower() in SKIP_DIRS or p.lower().endswith(".egg-info") for p in path.parts)
254
+
255
+
256
+ def _looks_like_test(path: Path) -> bool:
257
+ parts = [p.lower() for p in path.parts]
258
+ if "tests" in parts or "test" in parts:
259
+ return True
260
+ name = path.name.lower()
261
+ return name.startswith("test_") or name.endswith("_test.py") or name == "conftest.py"
262
+
263
+
264
+ def _count_raises(source: str) -> int:
265
+ try:
266
+ tree = ast.parse(source)
267
+ except SyntaxError:
268
+ return 0
269
+ return sum(1 for node in ast.walk(tree) if isinstance(node, ast.Raise) and node.exc is not None)