Skip to content

Commit 54905a4

Browse files
Emily RaganEmily Ragan
authored andcommitted
2472 | get_target_file now returns None/nil when file is not found
1 parent dc2da6c commit 54905a4

File tree

5 files changed

+770
-3
lines changed

5 files changed

+770
-3
lines changed

docs.openc3.com/docs/guides/scripting-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ match value:
319319

320320
### get_target_file
321321

322-
Return a file handle to a file in the target directory
322+
Return a file handle to a file in the target directory. Returns `None` (Python) or `nil` (Ruby) if the file is not found.
323323

324324
<Tabs groupId="script-language">
325325
<TabItem value="ruby" label="Ruby Syntax">

openc3/lib/openc3/script/storage.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ def get_target_file(path, original: false, scope: $openc3_scope)
120120
part = "targets"
121121
redo
122122
else
123-
raise e
123+
# Return nil instead of raising when file not found
124+
return nil
124125
end
125126
end
126127
break

openc3/python/openc3/script/storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def get_target_file(path: str, original: bool = False, scope: str = OPENC3_SCOPE
125125
part = "targets"
126126
# redo
127127
else:
128-
raise error
128+
return None
129129

130130

131131
# These are helper methods ... should not be used directly
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Copyright 2025 OpenC3, Inc.
2+
# All Rights Reserved.
3+
#
4+
# This program is free software; you can modify and/or redistribute it
5+
# under the terms of the GNU Affero General Public License
6+
# as published by the Free Software Foundation; version 3 with
7+
# attribution addendums as found in the LICENSE.txt
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
14+
# This file may also be used under the terms of a commercial license
15+
# if purchased from OpenC3, Inc.
16+
17+
import os
18+
import unittest
19+
from unittest.mock import Mock, patch
20+
from test.test_helper import mock_redis
21+
from openc3.script.storage import get_target_file
22+
import openc3.script
23+
24+
25+
class TestGetTargetFile(unittest.TestCase):
26+
def setUp(self):
27+
self.redis = mock_redis(self)
28+
# Mock the API_SERVER
29+
self.api_server_mock = Mock()
30+
openc3.script.API_SERVER = self.api_server_mock
31+
openc3.script.OPENC3_IN_CLUSTER = True
32+
os.environ["OPENC3_SCOPE"] = "DEFAULT"
33+
os.environ["OPENC3_CLOUD"] = "local"
34+
35+
def tearDown(self):
36+
# Clean up environment
37+
if "OPENC3_LOCAL_MODE" in os.environ:
38+
del os.environ["OPENC3_LOCAL_MODE"]
39+
40+
@patch("openc3.script.storage.requests.get")
41+
def test_get_target_file_from_targets_modified(self, mock_get):
42+
"""Test successfully retrieving a file from targets_modified"""
43+
# Setup mock responses
44+
presigned_response = Mock()
45+
presigned_response.status_code = 201
46+
presigned_response.text = '{"url": "/presigned/url"}'
47+
self.api_server_mock.request.return_value = presigned_response
48+
49+
# Mock the file content response
50+
file_content = b"test file content"
51+
mock_file_response = Mock()
52+
mock_file_response.status_code = 200
53+
mock_file_response.content = file_content
54+
mock_get.return_value = mock_file_response
55+
56+
# Call the function
57+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
58+
59+
# Verify API calls
60+
self.api_server_mock.request.assert_called_once_with(
61+
"get",
62+
"/openc3-api/storage/download/DEFAULT/targets_modified/INST/procedures/test.rb",
63+
query={"bucket": "OPENC3_CONFIG_BUCKET", "internal": True},
64+
scope="DEFAULT",
65+
)
66+
67+
# Verify the result
68+
self.assertIsNotNone(result)
69+
content = result.read()
70+
self.assertEqual(content, file_content)
71+
72+
@patch("openc3.script.storage.requests.get")
73+
def test_get_target_file_falls_back_to_targets(self, mock_get):
74+
"""Test falling back to targets directory when targets_modified fails"""
75+
# First call fails (targets_modified)
76+
# Second call succeeds (targets)
77+
presigned_response_success = Mock()
78+
presigned_response_success.status_code = 201
79+
presigned_response_success.text = '{"url": "/presigned/url"}'
80+
81+
self.api_server_mock.request.return_value = presigned_response_success
82+
83+
# First request fails (targets_modified), second succeeds (targets)
84+
file_content = b"original file content"
85+
mock_file_response_fail = Mock()
86+
mock_file_response_fail.status_code = 404
87+
mock_file_response_success = Mock()
88+
mock_file_response_success.status_code = 200
89+
mock_file_response_success.content = file_content
90+
91+
mock_get.side_effect = [mock_file_response_fail, mock_file_response_success]
92+
93+
# Call the function
94+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
95+
96+
# Verify it tried targets_modified first, then targets
97+
self.assertEqual(self.api_server_mock.request.call_count, 2)
98+
first_call = self.api_server_mock.request.call_args_list[0]
99+
self.assertIn("targets_modified", first_call[0][1])
100+
second_call = self.api_server_mock.request.call_args_list[1]
101+
self.assertIn("targets/INST", second_call[0][1])
102+
103+
# Verify the result
104+
self.assertIsNotNone(result)
105+
content = result.read()
106+
self.assertEqual(content, file_content)
107+
108+
@patch("openc3.script.storage.requests.get")
109+
def test_get_target_file_with_original_true(self, mock_get):
110+
"""Test retrieving original file directly from targets directory"""
111+
# Setup mock responses
112+
presigned_response = Mock()
113+
presigned_response.status_code = 201
114+
presigned_response.text = '{"url": "/presigned/url"}'
115+
self.api_server_mock.request.return_value = presigned_response
116+
117+
# Mock the file content response
118+
file_content = b"original file content"
119+
mock_file_response = Mock()
120+
mock_file_response.status_code = 200
121+
mock_file_response.content = file_content
122+
mock_get.return_value = mock_file_response
123+
124+
# Call the function with original=True
125+
result = get_target_file("INST/procedures/test.rb", original=True, scope="DEFAULT")
126+
127+
# Verify it only called targets, not targets_modified
128+
self.api_server_mock.request.assert_called_once()
129+
call_args = self.api_server_mock.request.call_args[0]
130+
self.assertIn("targets/INST", call_args[1])
131+
self.assertNotIn("targets_modified", call_args[1])
132+
133+
# Verify the result
134+
self.assertIsNotNone(result)
135+
content = result.read()
136+
self.assertEqual(content, file_content)
137+
138+
@patch("openc3.script.storage.requests.get")
139+
def test_get_target_file_not_found(self, mock_get):
140+
"""Test return value of None when file is not found in either location"""
141+
# Setup mock responses
142+
presigned_response = Mock()
143+
presigned_response.status_code = 201
144+
presigned_response.text = '{"url": "/presigned/url"}'
145+
self.api_server_mock.request.return_value = presigned_response
146+
147+
# Both requests fail
148+
mock_file_response = Mock()
149+
mock_file_response.status_code = 404
150+
mock_get.return_value = mock_file_response
151+
152+
# Call the function and expect None
153+
result = get_target_file("INST/procedures/nonexistent.rb", original=False, scope="DEFAULT")
154+
155+
self.assertIsNone(result)
156+
157+
@patch("openc3.script.storage.LocalMode.open_local_file")
158+
def test_get_target_file_local_mode(self, mock_open_local):
159+
"""Test retrieving file in local mode"""
160+
os.environ["OPENC3_LOCAL_MODE"] = "true"
161+
162+
# Mock local file
163+
mock_local_file = Mock()
164+
mock_local_file.read.return_value = b"local file content"
165+
mock_open_local.return_value = mock_local_file
166+
167+
# Call the function
168+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
169+
170+
# Verify local file was opened
171+
mock_open_local.assert_called_once_with("INST/procedures/test.rb", scope="DEFAULT")
172+
173+
# Verify the result
174+
self.assertIsNotNone(result)
175+
content = result.read()
176+
self.assertEqual(content, b"local file content")
177+
178+
@patch("openc3.script.storage.LocalMode.open_local_file")
179+
@patch("openc3.script.storage.requests.get")
180+
def test_get_target_file_local_mode_falls_back(self, mock_get, mock_open_local):
181+
"""Test falling back to remote when local file not found"""
182+
os.environ["OPENC3_LOCAL_MODE"] = "true"
183+
184+
# Mock local file not found
185+
mock_open_local.return_value = None
186+
187+
# Setup mock responses for remote
188+
presigned_response = Mock()
189+
presigned_response.status_code = 201
190+
presigned_response.text = '{"url": "/presigned/url"}'
191+
self.api_server_mock.request.return_value = presigned_response
192+
193+
# Mock the file content response
194+
file_content = b"remote file content"
195+
mock_file_response = Mock()
196+
mock_file_response.status_code = 200
197+
mock_file_response.content = file_content
198+
mock_get.return_value = mock_file_response
199+
200+
# Call the function
201+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
202+
203+
# Verify it tried local first
204+
mock_open_local.assert_called_once()
205+
206+
# Verify it fell back to remote
207+
self.api_server_mock.request.assert_called()
208+
209+
# Verify the result
210+
self.assertIsNotNone(result)
211+
content = result.read()
212+
self.assertEqual(content, file_content)
213+
214+
@patch("openc3.script.storage.requests.get")
215+
def test_get_target_file_returns_tempfile(self, mock_get):
216+
"""Test that the returned file is a NamedTemporaryFile"""
217+
# Setup mock responses
218+
presigned_response = Mock()
219+
presigned_response.status_code = 201
220+
presigned_response.text = '{"url": "/presigned/url"}'
221+
self.api_server_mock.request.return_value = presigned_response
222+
223+
# Mock the file content response
224+
file_content = b"test file content"
225+
mock_file_response = Mock()
226+
mock_file_response.status_code = 200
227+
mock_file_response.content = file_content
228+
mock_get.return_value = mock_file_response
229+
230+
# Call the function
231+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
232+
233+
# Verify the result is a file-like object
234+
self.assertTrue(hasattr(result, "read"))
235+
self.assertTrue(hasattr(result, "seek"))
236+
self.assertTrue(hasattr(result, "name"))
237+
238+
# Verify file position is at the beginning
239+
first_read = result.read()
240+
self.assertEqual(first_read, file_content)
241+
242+
# Verify we can seek and read again
243+
result.seek(0)
244+
second_read = result.read()
245+
self.assertEqual(second_read, file_content)
246+
247+
@patch("openc3.script.storage.requests.get")
248+
def test_get_target_file_with_custom_scope(self, mock_get):
249+
"""Test retrieving file with a custom scope"""
250+
# Setup mock responses
251+
presigned_response = Mock()
252+
presigned_response.status_code = 201
253+
presigned_response.text = '{"url": "/presigned/url"}'
254+
self.api_server_mock.request.return_value = presigned_response
255+
256+
# Mock the file content response
257+
file_content = b"custom scope file"
258+
mock_file_response = Mock()
259+
mock_file_response.status_code = 200
260+
mock_file_response.content = file_content
261+
mock_get.return_value = mock_file_response
262+
263+
# Call the function with custom scope
264+
result = get_target_file("INST/procedures/test.rb", original=False, scope="CUSTOM_SCOPE")
265+
266+
# Verify the scope was used correctly
267+
self.api_server_mock.request.assert_called_once()
268+
call_args = self.api_server_mock.request.call_args
269+
self.assertIn("CUSTOM_SCOPE/targets_modified", call_args[0][1])
270+
self.assertEqual(call_args[1]["scope"], "CUSTOM_SCOPE")
271+
272+
# Verify the result
273+
self.assertIsNotNone(result)
274+
content = result.read()
275+
self.assertEqual(content, file_content)
276+
277+
def test_get_target_file_presigned_url_failure(self):
278+
"""Test return value of None when presigned URL request fails"""
279+
# Setup mock response with failure
280+
presigned_response = Mock()
281+
presigned_response.status_code = 500
282+
self.api_server_mock.request.return_value = presigned_response
283+
284+
# Call the function and expect None
285+
result = get_target_file("INST/procedures/test.rb", original=False, scope="DEFAULT")
286+
287+
self.assertIsNone(result)
288+
289+
290+
if __name__ == "__main__":
291+
unittest.main()

0 commit comments

Comments
 (0)