Skip to content

Commit 467c2d0

Browse files
authored
Handle PermissionError gracefully during recursive search for upstream visits (#731)
In an actual production environment, the directories containing data will be interspersed with other directories that we don't have access to, which will lead to PermissionError occurences during the recursive search. This PR nests the directory scanning logic in a try-except block, logging a warning of the directory that could not be accessed before safely moving on to try the other directories until the maximum depth set is reached.
1 parent 26c474e commit 467c2d0

File tree

2 files changed

+89
-20
lines changed

2 files changed

+89
-20
lines changed

src/murfey/server/api/session_shared.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,25 +158,29 @@ def _recursive_search(
158158
return result
159159

160160
# Walk through the directories
161-
for entry in os.scandir(dirpath):
162-
if entry.is_dir():
163-
# Update dictionary with match and stop recursing for this route
164-
if (
165-
search_string in entry.name
166-
if partial_match
167-
else search_string == entry.name
168-
):
169-
if result is not None: # MyPy needs this 'is not None' check
170-
result[entry.name] = Path(entry.path)
171-
else:
172-
# Continue searching down this route until max depth is reached
173-
result = _recursive_search(
174-
dirpath=entry.path,
175-
search_string=search_string,
176-
partial_match=partial_match,
177-
max_depth=max_depth - 1,
178-
result=result,
179-
)
161+
try:
162+
for entry in os.scandir(dirpath):
163+
if entry.is_dir():
164+
# Update dictionary with match and stop recursing for this route
165+
if (
166+
search_string in entry.name
167+
if partial_match
168+
else search_string == entry.name
169+
):
170+
if result is not None: # MyPy needs this 'is not None' check
171+
result[entry.name] = Path(entry.path)
172+
else:
173+
# Continue searching down this route until max depth is reached
174+
result = _recursive_search(
175+
dirpath=entry.path,
176+
search_string=search_string,
177+
partial_match=partial_match,
178+
max_depth=max_depth - 1,
179+
result=result,
180+
)
181+
# Safely skip the directory if it can't access it
182+
except PermissionError:
183+
logger.warning(f"Unable to access {dirpath}; skipping", exc_info=True)
180184
return result
181185

182186
murfey_session = db.exec(

tests/server/api/test_session_shared.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def test_find_upstream_visits(
3333
upstream_data_dirs = {}
3434
for n in range(10):
3535
upstream_instrument = f"{instrument_name}{str(n).zfill(2)}"
36+
# Create path to visit
3637
upstream_visit = (
3738
tmp_path / f"{upstream_instrument}/data/2020/{visit_name_root}-{n}"
3839
)
@@ -49,6 +50,14 @@ def test_find_upstream_visits(
4950
upstream_visit.parent.mkdir(parents=True, exist_ok=True)
5051
upstream_visit.touch(exist_ok=True)
5152

53+
# Create junk directories with multiple levels to test recursion logic with
54+
junk_directories = [
55+
tmp_path / f"{upstream_instrument}/data/junk/directory/number/{n}"
56+
for n in range(5)
57+
]
58+
for dirpath in junk_directories:
59+
dirpath.mkdir(parents=True, exist_ok=True)
60+
5261
# Mock the MachineConfig for this instrument
5362
mock_machine_config = MagicMock(spec=MachineConfig)
5463
mock_machine_config.upstream_data_directories = upstream_data_dirs
@@ -57,10 +66,66 @@ def test_find_upstream_visits(
5766
)
5867
mock_get_machine_config.return_value = {instrument_name: mock_machine_config}
5968

60-
# Run the function:
69+
# Run the function
6170
result = find_upstream_visits(session_id=session_id, db=mock_murfey_db)
6271

72+
# Check that the expected directories are returned
73+
assert result == upstream_visits
74+
75+
76+
def test_find_upstream_visits_permission_error(
77+
mocker: MockerFixture,
78+
tmp_path: Path,
79+
):
80+
# Get the visit, instrument name, and session ID
81+
visit_name_root = f"{ExampleVisit.proposal_code}{ExampleVisit.proposal_number}"
82+
visit_name = f"{visit_name_root}-{ExampleVisit.visit_number}"
83+
instrument_name = ExampleVisit.instrument_name
84+
session_id = ExampleVisit.murfey_session_id
85+
6386
# Mock the database call
87+
mock_murfey_session_row = MagicMock()
88+
mock_murfey_session_row.visit = visit_name
89+
mock_murfey_session_row.instrument_name = instrument_name
90+
mock_murfey_db = MagicMock()
91+
mock_murfey_db.exec.return_value.one.return_value = mock_murfey_session_row
92+
93+
# Create mock upstream visit directories and necessary data structures
94+
upstream_visits: dict[str, dict] = {}
95+
upstream_data_dirs = {}
96+
for n in range(10):
97+
upstream_instrument = f"{instrument_name}{str(n).zfill(2)}"
98+
upstream_visit = (
99+
tmp_path / f"{upstream_instrument}/data/2020/{visit_name_root}-{n}"
100+
)
101+
# Create some as directories, and some as files
102+
if n % 2:
103+
upstream_visit.mkdir(parents=True, exist_ok=True)
104+
upstream_data_dirs[upstream_instrument] = upstream_visit.parent.parent
105+
# With os.scandir set to raise PermissionError, dictionaries should be empty
106+
upstream_visits[upstream_instrument] = {}
107+
else:
108+
upstream_visit.parent.mkdir(parents=True, exist_ok=True)
109+
upstream_visit.touch(exist_ok=True)
110+
111+
# Mock the MachineConfig for this instrument
112+
mock_machine_config = MagicMock(spec=MachineConfig)
113+
mock_machine_config.upstream_data_directories = upstream_data_dirs
114+
mock_get_machine_config = mocker.patch(
115+
"murfey.server.api.session_shared.get_machine_config",
116+
)
117+
mock_get_machine_config.return_value = {instrument_name: mock_machine_config}
118+
119+
# Mock the 'os.scandir' function used
120+
mocker.patch(
121+
"murfey.server.api.session_shared.os.scandir",
122+
side_effect=PermissionError(),
123+
)
124+
125+
# Run the function:
126+
result = find_upstream_visits(session_id=session_id, db=mock_murfey_db)
127+
128+
# With 'os.scandir' mocked to raise PermissionError, no entries should be returned
64129
assert result == upstream_visits
65130

66131

0 commit comments

Comments
 (0)