Skip to content

Commit fd4299c

Browse files
chore(tidy3d): FXC-4466-standardize-chained-exception-messages-enforce-via-ci
1 parent e33c7e4 commit fd4299c

File tree

23 files changed

+246
-35
lines changed

23 files changed

+246
-35
lines changed

.github/workflows/tidy3d-python-client-tests.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,28 @@ jobs:
224224
echo "extras_integration_tests=$extras_integration_tests"
225225
echo "test_type=$test_type"
226226
227+
check-error-blind-chaining:
228+
name: check blind-chaining of error messages
229+
needs: determine-test-scope
230+
if: needs.determine-test-scope.outputs.code_quality_tests == 'true'
231+
runs-on: ubuntu-latest
232+
steps:
233+
- uses: actions/checkout@v4
234+
with:
235+
fetch-depth: 1
236+
submodules: false
237+
persist-credentials: false
238+
239+
- name: set-python-3.10
240+
uses: actions/setup-python@v4
241+
with:
242+
python-version: '3.10'
243+
244+
- name: Run blind-chaining test
245+
run: |
246+
set -euo pipefail
247+
python scripts/check_blind_chaining.py
248+
227249
lint:
228250
needs: determine-test-scope
229251
if: needs.determine-test-scope.outputs.code_quality_tests == 'true'
@@ -981,6 +1003,7 @@ jobs:
9811003
- determine-test-scope
9821004
- local-tests
9831005
- remote-tests
1006+
- check-error-blind-chaining
9841007
- lint
9851008
- mypy
9861009
- verify-schema-change
@@ -993,6 +1016,12 @@ jobs:
9931016
- extras-integration-tests
9941017
runs-on: ubuntu-latest
9951018
steps:
1019+
- name: check-error-blind-chaining
1020+
if: ${{ needs.determine-test-scope.outputs.code_quality_tests == 'true' && needs.check-error-blind-chaining.result != 'success' && needs.check-error-blind-chaining.result != 'skipped' }}
1021+
run: |
1022+
echo "❌ Detected blind-chained error messages."
1023+
exit 1
1024+
9961025
- name: check-linting-result
9971026
if: ${{ needs.determine-test-scope.outputs.code_quality_tests == 'true' && needs.lint.result != 'success' && needs.lint.result != 'skipped' }}
9981027
run: |

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ repos:
1111
stages: [pre-commit]
1212
- id: ruff-format
1313
stages: [pre-commit]
14+
- repo: local
15+
hooks:
16+
- id: blind-chaining-check
17+
name: blind-chaining-check
18+
entry: python scripts/check_blind_chaining.py
19+
language: system
20+
types: [python]
21+
pass_filenames: false
1422
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
1523
rev: v9.22.0
1624
hooks:

scripts/check_blind_chaining.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Detect blind exception chaining that hides original errors.
2+
3+
Usage:
4+
python scripts/check_blind_chaining.py [paths ...]
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import ast
10+
import sys
11+
from collections.abc import Iterable
12+
from pathlib import Path
13+
14+
SKIP_COMMENT = "# noqa: BC"
15+
ALLOWLIST_DIRS = (
16+
Path("tidy3d/web/cli/develop"),
17+
Path("tidy3d/packaging"),
18+
)
19+
ALLOWLIST_PATHS = (
20+
Path("tidy3d/packaging.py"),
21+
Path("tidy3d/updater.py"),
22+
)
23+
24+
25+
def contains_name(node: ast.AST | None, target: str) -> bool:
26+
"""Return True if any ``ast.Name`` inside ``node`` matches ``target``."""
27+
28+
if node is None:
29+
return False
30+
return any(isinstance(child, ast.Name) and child.id == target for child in ast.walk(node))
31+
32+
33+
def iter_python_files(paths: Iterable[Path]) -> Iterable[Path]:
34+
"""Yield Python files under the provided paths, respecting skips."""
35+
36+
for root in paths:
37+
if root.is_file() and root.suffix == ".py":
38+
yield root
39+
continue
40+
if not root.is_dir():
41+
continue
42+
yield from root.rglob("*.py")
43+
44+
45+
def _primary_message_expr(exc: ast.AST) -> ast.AST | None:
46+
"""
47+
Heuristic: identify the expression that most serializers/GUI layers display.
48+
49+
- For `SomeError("msg", ...)`: first positional arg.
50+
- For `SomeError(message="msg")` / `detail=...`: preferred keyword.
51+
- Otherwise: None (unknown).
52+
"""
53+
if not isinstance(exc, ast.Call):
54+
return None
55+
56+
if exc.args:
57+
return exc.args[0]
58+
return None
59+
60+
61+
def find_blind_chaining(path: Path) -> list[tuple[Path, int, int, str]]:
62+
"""
63+
Find `raise <new_exc> from <cause>` where `<cause>` is *not* referenced in the
64+
user-visible message expression (first positional arg or message-like kwarg).
65+
66+
Returns: (path, lineno, col_offset, cause_name)
67+
"""
68+
errors: list[tuple[Path, int, int, str]] = []
69+
try:
70+
src = path.read_text(encoding="utf-8")
71+
tree = ast.parse(src, filename=str(path))
72+
except SyntaxError:
73+
return errors
74+
75+
lines = src.splitlines()
76+
77+
for node in ast.walk(tree):
78+
if not isinstance(node, ast.Raise):
79+
continue
80+
81+
# Handles `raise from e` (node.exc is None) safely.
82+
if node.exc is None:
83+
continue
84+
85+
# Ignore `raise X from None` (intentional suppression).
86+
if isinstance(node.cause, ast.Constant) and node.cause.value is None:
87+
continue
88+
89+
# Only enforce for simple `from <name>` patterns (e.g., `except ... as e:`).
90+
if not isinstance(node.cause, ast.Name):
91+
continue
92+
cause_name = node.cause.id
93+
94+
# Avoid noisy false positives for `raise existing_exc from e`.
95+
if isinstance(node.exc, ast.Name):
96+
continue
97+
98+
# Prefer checking the “primary message” expression if we can identify it.
99+
msg_expr = _primary_message_expr(node.exc)
100+
101+
if msg_expr is not None:
102+
ok = contains_name(msg_expr, cause_name)
103+
else:
104+
# Fallback: at least require the cause to be used somewhere in the exc expression.
105+
ok = contains_name(node.exc, cause_name)
106+
107+
if not ok:
108+
lineno = getattr(node, "lineno", 1)
109+
col = getattr(node, "col_offset", 0)
110+
if lineno - 1 < len(lines):
111+
if SKIP_COMMENT in lines[lineno - 1]:
112+
continue
113+
errors.append((path, lineno, col, cause_name))
114+
115+
return errors
116+
117+
118+
def is_allowlisted(path: Path) -> bool:
119+
"""Return True if ``path`` resides in an allowlisted directory."""
120+
121+
resolved_path = path.resolve()
122+
if any(resolved_path == allow_path.resolve() for allow_path in ALLOWLIST_PATHS):
123+
return True
124+
for allow_dir in ALLOWLIST_DIRS:
125+
if resolved_path.is_relative_to(allow_dir.resolve()):
126+
return True
127+
return False
128+
129+
130+
def main(argv: list[str]) -> int:
131+
paths = [Path(arg) for arg in argv] if argv else [Path("tidy3d")]
132+
existing_paths = [path for path in paths if path.exists()]
133+
if not existing_paths:
134+
existing_paths = [Path(".")]
135+
136+
failures: list[tuple[Path, int, int, str]] = []
137+
for file_path in iter_python_files(existing_paths):
138+
failures.extend(find_blind_chaining(file_path))
139+
140+
filtered_failures = [
141+
(path, lineno, cause_name)
142+
for path, lineno, _, cause_name in failures
143+
if not is_allowlisted(path)
144+
]
145+
146+
if filtered_failures:
147+
print("Blind exception chaining detected (missing original cause in raised message):")
148+
for path, lineno, cause_name in sorted(filtered_failures):
149+
print(
150+
f" {path}:{lineno} cause variable '{cause_name}' not referenced in raised exception"
151+
)
152+
print(f"Add '{SKIP_COMMENT}' to the raise line to suppress intentionally.")
153+
return 1
154+
155+
print("No blind exception chaining instances found.")
156+
return 0
157+
158+
159+
if __name__ == "__main__":
160+
sys.exit(main(sys.argv[1:]))

scripts/make_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def main(args):
8888
except subprocess.CalledProcessError as exc:
8989
raise RuntimeError(
9090
"Ruff formatting failed. Your script might not be compatible with make_script.py. "
91-
"This could be due to unsupported features like CustomMedium."
91+
f"This could be due to unsupported features like CustomMedium.\n{exc!s}"
9292
) from exc
9393
finally:
9494
# remove the temporary file

tidy3d/components/base.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def _get_type_value(cls, obj: dict[str, Any]) -> str:
208208
try:
209209
type_value = obj[TYPE_TAG_STR]
210210
except KeyError as exc:
211-
raise ValueError(f'Missing "{TYPE_TAG_STR}" in data') from exc
211+
raise ValueError(f'Missing "{TYPE_TAG_STR}" in data: {exc!s}') from exc
212212
if not isinstance(type_value, str) or not type_value:
213213
raise ValueError(f'Invalid "{TYPE_TAG_STR}" value: {type_value!r}')
214214
return type_value
@@ -218,7 +218,7 @@ def _get_registered_class(cls, type_value: str) -> type[Tidy3dBaseModel]:
218218
try:
219219
return TYPE_TO_CLASS_MAP[type_value]
220220
except KeyError as exc:
221-
raise ValueError(f"Unknown type: {type_value}") from exc
221+
raise ValueError(f"Unknown type: {type_value}: {exc!s}") from exc
222222

223223
@classmethod
224224
def _should_dispatch_to(cls, target_cls: type[Tidy3dBaseModel]) -> bool:
@@ -387,7 +387,8 @@ def updated_copy(
387387
raise AttributeError(
388388
f"Could not field field '{field_name}' in the sub-component `path`. "
389389
f"Found fields of '{tuple(self.__fields__.keys())}'. "
390-
"Please double check the `path` passed to `.updated_copy()`."
390+
"Please double check the `path` passed to `.updated_copy()`: "
391+
f"{e!s}"
391392
) from e
392393

393394
if isinstance(sub_component, (list, tuple)):

tidy3d/components/base_sim/data/sim_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def data_monitors_match_sim(cls, values):
6767
except Tidy3dKeyError as exc:
6868
raise DataError(
6969
f"Data with monitor name '{monitor_name}' supplied "
70-
f"but not found in the original '{sim.type}'."
70+
f"but not found in the original '{sim.type}': {exc!s}"
7171
) from exc
7272
return values
7373

tidy3d/components/data/data_array.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,8 @@ def _with_updated_data(self, data: np.ndarray, coords: dict[str, Any]) -> DataAr
520520
raise ValueError(
521521
"Couldn't reshape the supplied 'data' to update 'DataArray'. The provided data was "
522522
f"of shape {data.shape} and tried to reshape to {new_shape}. If you encounter this "
523-
"error please raise an issue on the tidy3d github repository with the context."
523+
"error please raise an issue on the tidy3d github repository with the context: "
524+
f"{e!s}"
524525
) from e
525526

526527
# broadcast data to repeat data along the selected dimensions to match mask

tidy3d/components/data/sim_data.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,5 +1382,6 @@ def to_mat_file(self, fname: PathLike, **kwargs: Any) -> None:
13821382
savemat(fname, modified_sim_dict, **kwargs)
13831383
except Exception as e:
13841384
raise ValueError(
1385-
"Could not save supplied 'SimulationData' to file. As this is an experimental feature, we may not be able to support the contents of your dataset. If you receive this error, please feel free to raise an issue on our front end repository so we can investigate."
1385+
"Could not save supplied 'SimulationData' to file. As this is an experimental feature, we may not be able to support the contents of your dataset. If you receive this error, please feel free to raise an issue on our front end repository so we can investigate "
1386+
f"{e!s}"
13861387
) from e

tidy3d/components/field_projection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def _far_fields_for_surface(
423423
currents_f = currents.sel(f=frequency)
424424
except Exception as e:
425425
raise SetupError(
426-
f"Frequency {frequency} not found in fields for monitor '{surface.monitor.name}'."
426+
f"Frequency {frequency} not found in fields for monitor '{surface.monitor.name}': {e!s}"
427427
) from e
428428

429429
idx_w, idx_uv = surface.monitor.pop_axis((0, 1, 2), axis=surface.axis)

tidy3d/components/geometry/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1473,7 +1473,7 @@ def to_gds_file(
14731473
except ImportError as e:
14741474
raise Tidy3dImportError(
14751475
"Python module 'gdstk' not found. To export geometries to .gds "
1476-
"files, please install it."
1476+
f"files, please install it: {e!s}"
14771477
) from e
14781478

14791479
library = gdstk.Library()

0 commit comments

Comments
 (0)