Skip to content

Commit b6e93b1

Browse files
committed
fix(component-templates): preserve pre-existing user templates on save
When saving a component template to the user templates file, existing templates for other component types were silently overwritten. Now the file is read before writing and merged so only the saved component's entry is updated while all others are retained. Also adds broader OSError/Exception handling to _load_system_templates and _load_user_templates so read failures degrade gracefully to an empty dict instead of propagating an unhandled exception.
1 parent 59eb235 commit b6e93b1

File tree

2 files changed

+96
-23
lines changed

2 files changed

+96
-23
lines changed

ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,16 @@ def load_component_templates(self) -> dict[str, list[dict]]:
8888

8989
return merged_templates
9090

91-
def _load_system_templates(self) -> dict[str, list[dict]]:
91+
def _load_system_templates(self, filepath: str = "") -> dict[str, list[dict]]:
9292
"""
9393
Load system component templates.
9494
9595
:return: The system component templates as a dictionary
9696
"""
97-
templates_filename = "system_vehicle_components_template.json"
98-
templates_dir = ProgramSettings.get_templates_base_dir()
99-
filepath = os_path.join(templates_dir, templates_filename)
97+
if not filepath:
98+
templates_filename = "system_vehicle_components_template.json"
99+
templates_dir = ProgramSettings.get_templates_base_dir()
100+
filepath = os_path.join(templates_dir, templates_filename)
100101

101102
templates = {}
102103
try:
@@ -106,6 +107,10 @@ def _load_system_templates(self) -> dict[str, list[dict]]:
106107
logging_debug(_("System component templates file '%s' not found."), filepath)
107108
except JSONDecodeError:
108109
logging_error(_("Error decoding JSON system component templates from file '%s'."), filepath)
110+
except OSError as e:
111+
logging_error(_("Error reading system component templates from file '%s': %s"), filepath, e)
112+
except Exception as e: # pylint: disable=broad-exception-caught
113+
logging_error(_("Unexpected error reading system component templates from file '%s': %s"), filepath, e)
109114
return templates
110115

111116
def _load_user_templates(self) -> dict[str, list[dict]]:
@@ -126,6 +131,10 @@ def _load_user_templates(self) -> dict[str, list[dict]]:
126131
logging_debug(_("User component templates file '%s' not found."), filepath)
127132
except JSONDecodeError:
128133
logging_error(_("Error decoding JSON user component templates from file '%s'."), filepath)
134+
except OSError as e:
135+
logging_error(_("Error reading user component templates from file '%s': %s"), filepath, e)
136+
except Exception as e: # pylint: disable=broad-exception-caught
137+
logging_error(_("Unexpected error reading user component templates from file '%s': %s"), filepath, e)
129138
return templates
130139

131140
def save_component_templates(self, templates: dict) -> tuple[bool, str]: # pylint: disable=too-many-branches
@@ -138,7 +147,8 @@ def save_component_templates(self, templates: dict) -> tuple[bool, str]: # pyli
138147
:param templates: The templates to save
139148
:return: A tuple of (error_occurred, message), where message is the filepath on success or error message on failure
140149
"""
141-
# Load system templates to compare against
150+
# Load system templates to compare against, system templates are read-only and come with the software,
151+
# so we need to load them to determine which templates are user-modified
142152
system_templates = self._load_system_templates()
143153

144154
# Determine which templates need to be saved to user file
@@ -213,19 +223,21 @@ def save_component_templates(self, templates: dict) -> tuple[bool, str]: # pyli
213223

214224
def save_component_templates_to_file(self, templates_to_save: dict[str, list[dict[str, Any]]]) -> tuple[bool, str]:
215225
templates_dir = ProgramSettings.get_templates_base_dir()
226+
existing_templates = {}
227+
filepath = ""
216228
if self.save_component_to_system_templates:
217-
# Save to system templates file.
229+
# Save to "developer" system templates file, only for AMC developers with local a local git repository
230+
# copy of the software who want to add new templates to the system templates file in their local git
231+
# repository and create a github pull-request with the newly added component templates.
218232
# System templates are part of the software installation and get updated when the program
219233
# is updated and deleted when the program is un-installed.
220234
templates_filename = "system_vehicle_components_template.json"
221235
# Check if local system template file exists, use local dir if so.
222-
# This allows developers to directly add templates to their local git repository
223-
# system template file and create a github pull-request with the newly added component templates
224236
try:
225237
local_dir = importlib_files("ardupilot_methodic_configurator") / "vehicle_templates"
226-
local_filepath = local_dir / templates_filename
227-
if os_path.exists(str(local_filepath)):
228-
templates_dir = str(local_dir)
238+
filepath = str(local_dir / templates_filename)
239+
if os_path.exists(filepath):
240+
existing_templates = self._load_system_templates(filepath)
229241
except (OSError, ValueError) as e:
230242
logging_debug("Failed to check local template file: %s", e)
231243
# Fall back to default templates_dir
@@ -234,23 +246,26 @@ def save_component_templates_to_file(self, templates_to_save: dict[str, list[dic
234246
# This file will not get deleted when the program is updated or un-installed.
235247
templates_filename = "user_vehicle_components_template.json"
236248

237-
# Create the directory if it doesn't exist
238-
try:
239-
os_makedirs(templates_dir, exist_ok=True)
240-
except Exception as e: # pylint: disable=broad-exception-caught
241-
msg = _("Failed to create templates directory '{}': {}").format(templates_dir, str(e))
242-
logging_error(msg)
243-
return True, msg
249+
# Create the directory if it doesn't exist
250+
try:
251+
os_makedirs(templates_dir, exist_ok=True)
252+
except Exception as e: # pylint: disable=broad-exception-caught
253+
msg = _("Failed to create templates directory '{}': {}").format(templates_dir, str(e))
254+
logging_error(msg)
255+
return True, msg
256+
257+
# Now create the file path and write to it
258+
# Use a consistent forward-slash path representation for return value so
259+
# tests comparing literal strings work across platforms.
260+
filepath = os_path.join(templates_dir, templates_filename)
261+
existing_templates = self._load_user_templates()
244262

245-
# Now create the file path and write to it
246-
# Use a consistent forward-slash path representation for return value so
247-
# tests comparing literal strings work across platforms.
248-
filepath = os_path.join(templates_dir, templates_filename)
249263
normalized_filepath = filepath.replace("\\", "/")
264+
templates = {**existing_templates, **templates_to_save} if existing_templates else templates_to_save
250265

251266
try:
252267
with open(filepath, "w", encoding="utf-8", newline="\n") as file: # use Linux line endings even on Windows
253-
json_dump(templates_to_save, file, indent=4)
268+
json_dump(templates, file, indent=4)
254269
return False, normalized_filepath # Success, return the filepath
255270
except FileNotFoundError:
256271
msg = _("File not found when writing to '{}': {}").format(normalized_filepath, _("Path not found"))

tests/test_backend_filesystem_vehicle_components.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,64 @@ def test_save_component_templates_to_system( # type: ignore[misc]
10491049
assert saved_templates["NewComponent"][0]["name"] == "New Template"
10501050
assert saved_templates["NewComponent"][0]["data"]["param"] == "new_value"
10511051

1052+
@patch.object(VehicleComponents, "_load_system_templates")
1053+
@patch.object(VehicleComponents, "_load_user_templates")
1054+
@patch("builtins.open", new_callable=mock_open)
1055+
@patch("ardupilot_methodic_configurator.backend_filesystem_vehicle_components.json_dump")
1056+
@patch("ardupilot_methodic_configurator.backend_filesystem_vehicle_components.os_makedirs")
1057+
@patch("ardupilot_methodic_configurator.backend_filesystem_program_settings.ProgramSettings.get_templates_base_dir")
1058+
def test_save_component_templates_preserves_existing_user_components( # type: ignore[misc] # pylint: disable=too-many-arguments, too-many-positional-arguments
1059+
self,
1060+
mock_get_base_dir,
1061+
mock_makedirs, # pylint: disable=unused-argument
1062+
mock_json_dump,
1063+
mock_file, # pylint: disable=unused-argument
1064+
mock_load_user,
1065+
mock_load_system,
1066+
) -> None:
1067+
"""
1068+
Save Component Templates Preserves Existing User Components.
1069+
1070+
GIVEN: A user templates file that already contains templates for "Battery"
1071+
WHEN: User saves a new template for "ESC" only
1072+
THEN: The resulting file should contain both the new "ESC" template and
1073+
the pre-existing "Battery" templates from the user file
1074+
"""
1075+
mock_get_base_dir.return_value = "/templates"
1076+
mock_load_system.return_value = {}
1077+
1078+
# Simulate pre-existing user templates for Battery
1079+
existing_user_templates = {
1080+
"Battery": [{"name": "6S 5000mAh LiPo", "data": {"Capacity mAh": 5000, "Cells": 6}}]
1081+
}
1082+
mock_load_user.return_value = existing_user_templates
1083+
1084+
# Save a new ESC template (Battery is NOT included in this call)
1085+
templates_to_save = {
1086+
"ESC": [{"name": "BLHeli32 60A", "data": {"protocol": "DSHOT600"}, "is_user_modified": True}]
1087+
}
1088+
1089+
result, msg = self.vehicle_components.save_component_templates(templates_to_save)
1090+
1091+
# Verify success
1092+
assert not result # False means success
1093+
assert msg == "/templates/user_vehicle_components_template.json"
1094+
1095+
# Verify the data written to disk contains both components
1096+
mock_json_dump.assert_called_once()
1097+
written_data = mock_json_dump.call_args[0][0]
1098+
1099+
# Pre-existing Battery templates must be preserved
1100+
assert "Battery" in written_data, "Pre-existing Battery templates were lost after saving ESC template"
1101+
assert written_data["Battery"] == existing_user_templates["Battery"]
1102+
1103+
# New ESC template must also be present
1104+
assert "ESC" in written_data, "Newly saved ESC template is missing"
1105+
assert len(written_data["ESC"]) == 1
1106+
assert written_data["ESC"][0]["name"] == "BLHeli32 60A"
1107+
# is_user_modified flag must be stripped before writing
1108+
assert "is_user_modified" not in written_data["ESC"][0]
1109+
10521110
@patch("ardupilot_methodic_configurator.backend_filesystem_vehicle_components.FilesystemJSONWithSchema")
10531111
def test_load_schema_invalid_json(self, mock_fs_class) -> None:
10541112
"""

0 commit comments

Comments
 (0)