Skip to content

Commit 7a5aa14

Browse files
committed
Error loading exclusion lists exits early without write
Why these changes are being introduced: If there are any errors loading an exclusion list for a source, e.g. the file is not accessible or not parseable, we want the transformation process to fail completely without writes to the TIMDEX dataset. As-is, we found that all records from the run will write to the dataset but with action="error". This is good when *some* records fails to understand what they were and why, but an exclusion list error will fail for *all* records making this not helpful. How this addresses that need: A new custom exception `CriticalError` is introduced that when raised will break the Transformer.__next__() iterator. In turn, this will bubble up to the CLI level and terminate the run with a non-zero process. This provides a mechanism from within a Transformer's transformation logic to raise an exception that will break the Transformer.__next__ iteration. Side effects of this change: * Failure to load an exclusion list will terminate the run early and explicitly. * In the future, catastrophic errors that suggest all records in the run will fail should also raise this new, custom exception. Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/USE-278
1 parent 090f1a6 commit 7a5aa14

5 files changed

Lines changed: 107 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ ignore = [
4242
"D205",
4343
"D212",
4444
"D402",
45+
"EM102",
4546
"G004",
4647
"PLR0912",
4748
"PLR0913",
4849
"PLR0915",
4950
"S321",
51+
"TRY003"
5052
]
5153

5254
# allow autofix behavior for specified rules

tests/sources/test_transformer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@ def test_load_exclusion_list(source_transformer, mock_s3_exclusion_list):
2323
]
2424

2525

26+
def test_next_iter_sets_action_skip_when_record_is_excluded(tmp_path):
27+
class ExcludingTransformer(Transformer):
28+
@classmethod
29+
def parse_source_file(cls, _source_file: str):
30+
return iter(())
31+
32+
@classmethod
33+
def get_main_titles(cls, _source_record):
34+
return ["Title"]
35+
36+
def get_source_link(self, source_record):
37+
return str(source_record["link"])
38+
39+
def get_timdex_record_id(self, source_record):
40+
return f"cool-repo:{source_record['id']}"
41+
42+
@classmethod
43+
def get_source_record_id(cls, source_record):
44+
return str(source_record["id"])
45+
46+
@classmethod
47+
def record_is_deleted(cls, _source_record):
48+
return False
49+
50+
def record_is_excluded(self, source_record):
51+
source_link = self.get_source_link(source_record)
52+
return source_link in (self.exclusion_list or [])
53+
54+
exclusion_list_path = tmp_path / "exclusions.csv"
55+
exclusion_list_path.write_text("https://example.com/exclude-me\n")
56+
transformer = ExcludingTransformer(
57+
"cool-repo",
58+
iter([{"id": "123", "link": "https://example.com/exclude-me"}]),
59+
exclusion_list_path=str(exclusion_list_path),
60+
)
61+
62+
dataset_record = next(transformer)
63+
assert dataset_record.action == "skip"
64+
assert dataset_record.transformed_record is None
65+
assert json.loads(dataset_record.source_record) == {
66+
"id": "123",
67+
"link": "https://example.com/exclude-me",
68+
}
69+
70+
2671
def test_transformer_get_transformer_returns_correct_class_name():
2772
assert Transformer.get_transformer("jpal") == Datacite
2873

tests/test_cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import mock
33

44
from transmogrifier.cli import main
5+
from transmogrifier.exceptions import CriticalError
56

67

78
def test_transform_no_sentry_not_verbose(
@@ -211,3 +212,36 @@ def test_transform_no_memory_fault_for_threaded_bs4_parsing(monkeypatch, tmp_pat
211212
check=False,
212213
)
213214
assert result.returncode == 0
215+
216+
217+
def test_transform_critical_error_prevents_writing(
218+
caplog, runner, source_input_file, source_transformer, empty_dataset_location
219+
):
220+
caplog.set_level("INFO")
221+
222+
with (
223+
mock.patch(
224+
"transmogrifier.cli.Transformer.load", return_value=source_transformer
225+
),
226+
mock.patch.object(
227+
source_transformer,
228+
"get_timdex_record_id",
229+
side_effect=CriticalError("Catastrophic failure, no records will work!"),
230+
),
231+
):
232+
result = runner.invoke(
233+
main,
234+
[
235+
"-s",
236+
"libguides",
237+
"-i",
238+
source_input_file,
239+
"--output-location",
240+
empty_dataset_location,
241+
],
242+
)
243+
244+
assert isinstance(result.exception, CriticalError)
245+
assert str(result.exception) == "Catastrophic failure, no records will work!"
246+
assert result.exit_code != 0
247+
assert "Completed transform, total records processed" not in caplog.text

transmogrifier/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ class SkippedRecordEvent(Exception): # noqa: N818
1919
def __init__(self, message: str | None = None, source_record_id: str | None = None):
2020
super().__init__(message)
2121
self.source_record_id = source_record_id
22+
23+
24+
class CriticalError(Exception):
25+
"""Exception raised for critical errors that should terminate the run."""
26+
27+
def __init__(self, message: str | None = None):
28+
super().__init__(message)

transmogrifier/sources/transformer.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323

2424
import transmogrifier.models as timdex
2525
from transmogrifier.config import SOURCES
26-
from transmogrifier.exceptions import DeletedRecordEvent, SkippedRecordEvent
26+
from transmogrifier.exceptions import (
27+
CriticalError,
28+
DeletedRecordEvent,
29+
SkippedRecordEvent,
30+
)
2731
from transmogrifier.helpers import (
2832
generate_citation,
2933
validate_date,
@@ -102,15 +106,22 @@ def load_exclusion_list(self) -> list[str]:
102106
Args:
103107
exclusion_list_path: Path to exclusion list file (s3://bucket/key or local
104108
path).
109+
110+
Raises:
111+
On error loading or parsing the file, raises CriticalError which will
112+
terminate the run.
105113
"""
106-
with smart_open.open(self.exclusion_list_path, "r") as exclusion_list:
107-
rows = exclusion_list.readlines()
108-
exclusion_list = [row.strip() for row in rows if row.strip()]
114+
try:
115+
with smart_open.open(self.exclusion_list_path, "r") as exclusion_list:
116+
rows = exclusion_list.readlines()
117+
exclusion_list = [row.strip() for row in rows if row.strip()]
118+
except Exception as exc:
119+
raise CriticalError(f"Could not load exclusion list: {exc}") from exc
120+
109121
logger.info(
110122
f"Loaded exclusion list from {self.exclusion_list_path} with "
111123
f"{len(exclusion_list)} entries"
112124
)
113-
logger.debug(exclusion_list)
114125
return exclusion_list
115126

116127
@final
@@ -149,6 +160,9 @@ def __next__(self) -> DatasetRecord:
149160
self.skipped_record_count += 1
150161
action = "skip"
151162

163+
except CriticalError:
164+
raise
165+
152166
except Exception as exception:
153167
self.error_record_count += 1
154168
message = f"Unhandled exception during record transformation: {exception}"

0 commit comments

Comments
 (0)