55"""
66
77import os
8+ import re
89import shutil
910from pathlib import Path
1011from typing import Dict , List , Optional
1516 MergeResult ,
1617 StanzaMergeResult ,
1718)
18- from bydefault .models .sort_models import Setting , Stanza
19+ from bydefault .models .sort_models import Stanza
1920from bydefault .utils .change_detection import _parse_conf_file
2021from bydefault .utils .sort_utils import ConfigSorter
2122
@@ -93,11 +94,6 @@ def write(self) -> None:
9394
9495 This method handles writing merged configurations back to the file system.
9596 It preserves stanza order and comments from the original files.
96-
97- Note:
98- Future refactoring: This file writing functionality should be
99- extracted to a common utility function shared with SortedConfigWriter
100- to ensure consistent behavior between the merge and sort commands.
10197 """
10298 for file_result in self .result .file_results :
10399 if not file_result .success :
@@ -106,8 +102,7 @@ def write(self) -> None:
106102 # Get the local file path
107103 local_file = file_result .file_path
108104
109- # Handle metadata files (they're in metadata directory
110- # instead of local/default)
105+ # Handle metadata files (they're in metadata directory instead of local/default)
111106 if local_file .name == "local.meta" and "metadata" in str (local_file ):
112107 default_file = self .ta_dir / "metadata" / "default.meta"
113108 else :
@@ -119,173 +114,185 @@ def write(self) -> None:
119114 self ._copy_file (local_file , default_file )
120115 continue
121116
122- # Create file content from scratch
123- local_sorter = ConfigSorter (local_file , verbose = self . verbose )
124- default_sorter = ConfigSorter (default_file , verbose = self . verbose )
117+ # Parse both files to get complete stanza information
118+ local_parsed = _parse_conf_file (local_file )
119+ default_parsed = _parse_conf_file (default_file )
125120
126- local_sorter .parse ()
127- default_sorter .parse ()
121+ # Read the full local file content for multiline detection
122+ with open (local_file , "r" , encoding = "utf-8" ) as f :
123+ local_content = f .read ()
128124
129- # For replace mode, completely replace stanzas from local
130- if self .mode == MergeMode .REPLACE :
131- for stanza_name , local_stanza in local_sorter .stanzas .items ():
132- if stanza_name in default_sorter .stanzas :
133- # Replace all settings in the stanza
134- default_stanza = default_sorter .stanzas [stanza_name ]
135- default_stanza .settings = {} # Clear existing settings
136-
137- # Copy settings from local to default
138- for setting_key , setting in local_stanza .settings .items ():
139- default_stanza .settings [setting_key ] = Setting (
140- key = setting .key ,
141- value = setting .value ,
142- line_number = setting .line_number ,
143- comments = setting .comments .copy (),
144- )
145- else :
146- # Add new stanza
147- # Create a new stanza with the same type
148- new_stanza = Stanza (
149- name = stanza_name ,
150- type = local_stanza .type ,
151- line_number = local_stanza .line_number ,
152- comments = local_stanza .comments .copy (),
153- blank_lines_after = local_stanza .blank_lines_after ,
154- )
155-
156- # Copy settings
157- for setting_key , setting in local_stanza .settings .items ():
158- new_stanza .settings [setting_key ] = Setting (
159- key = setting .key ,
160- value = setting .value ,
161- line_number = setting .line_number ,
162- comments = setting .comments .copy (),
163- )
164-
165- default_sorter .stanzas [stanza_name ] = new_stanza
166- else : # Merge mode
167- for stanza_name , local_stanza in local_sorter .stanzas .items ():
168- if stanza_name not in default_sorter .stanzas :
169- # Add new stanza
170- # Create a new stanza with the same type
171- new_stanza = Stanza (
172- name = stanza_name ,
173- type = local_stanza .type ,
174- line_number = local_stanza .line_number ,
175- comments = local_stanza .comments .copy (),
176- blank_lines_after = local_stanza .blank_lines_after ,
177- )
178-
179- # Copy settings
180- for setting_key , setting in local_stanza .settings .items ():
181- new_stanza .settings [setting_key ] = Setting (
182- key = setting .key ,
183- value = setting .value ,
184- line_number = setting .line_number ,
185- comments = setting .comments .copy (),
186- )
187-
188- default_sorter .stanzas [stanza_name ] = new_stanza
189- else :
190- # Update values in existing stanza
191- default_stanza = default_sorter .stanzas [stanza_name ]
192-
193- # Update or add settings from local
194- for setting_key , setting in local_stanza .settings .items ():
195- default_stanza .settings [setting_key ] = Setting (
196- key = setting .key ,
197- value = setting .value ,
198- line_number = setting .line_number ,
199- comments = setting .comments .copy (),
200- )
201-
202- # For multiline handling, we need to read the original local file
203- # to accurately preserve the structure of multiline values
204-
205- # Parse the original files to get proper multiline values
206- local_parsed = _parse_conf_file (local_file )
207- if default_file .exists ():
208- default_parsed = _parse_conf_file (default_file )
209- else :
210- default_parsed = {}
125+ # Create a backup of the default file
126+ default_backup = None
127+ try :
128+ import shutil
129+
130+ default_backup = default_file .with_suffix (
131+ default_file .suffix + ".bak"
132+ )
133+ shutil .copy (default_file , default_backup )
134+ except Exception :
135+ # If backup fails, continue without backup
136+ pass
137+
138+ # Prepare merged configuration
139+ merged_config = self ._prepare_merged_config (
140+ local_parsed , default_parsed
141+ )
211142
212- # Write files safely with fallback
213143 try :
214- # Write changes to a file
215- with open (default_file , "w" , encoding = "utf-8" ) as f :
216- # Manual formatting to handle multiline values properly
217- for stanza_name , stanza in default_sorter .stanzas .items ():
218- # Write stanza header
219- f .write (f"[{ stanza_name } ]\n " )
220-
221- # Write stanza settings, preserving multiline structure
222- for setting_key , setting in stanza .settings .items ():
223- if setting .value is not None :
224- # Check if this setting came from local and has a multiline structure
225- if (
226- stanza_name in local_parsed
227- and setting_key in local_parsed [stanza_name ]
228- and "\\ " in local_file .read_text ()
229- ):
230- # Find the original multiline value in the local file
231- with open (
232- local_file , "r" , encoding = "utf-8"
233- ) as local_f :
234- local_lines = local_f .readlines ()
235-
236- # Find the key in the original file
237- multiline_start = None
238- multiline_lines = []
239-
240- for i , line in enumerate (local_lines ):
241- if f"{ setting_key } =" in line :
242- multiline_start = i
243- multiline_lines .append (line .rstrip ())
244-
245- # Collect all continuation lines
246- j = i + 1
247- while (
248- j < len (local_lines )
249- and multiline_lines [- 1 ].endswith (
250- "\\ "
251- )
252- and not local_lines [j ]
253- .strip ()
254- .startswith ("[" )
255- ):
256- multiline_lines .append (
257- local_lines [j ].rstrip ()
258- )
259- j += 1
260-
261- break
262-
263- if multiline_start is not None :
264- # Found multiline value, write it preserving original structure
265- f .write (multiline_lines [0 ] + "\n " )
266- for ml_line in multiline_lines [1 :]:
267- f .write (ml_line + "\n " )
268- else :
269- # Fallback to normal handling
270- f .write (
271- f"{ setting_key } = { setting .value } \n "
272- )
273- else :
274- # Normal single-line value
275- f .write (f"{ setting_key } = { setting .value } \n " )
276- else :
277- f .write (f"{ setting_key } \n " )
278-
279- # Add blank line between stanzas
280- f .write ("\n " )
144+ # Write merged configuration to file
145+ self ._write_merged_file (default_file , merged_config , local_content )
146+
147+ # Delete backup if all went well
148+ if default_backup and default_backup .exists ():
149+ default_backup .unlink ()
281150 except Exception as e :
282- print (f"Error writing file: { str (e )} " )
283- # Fallback: Copy the local file to default if
284- # we can't write properly
285- self ._copy_file (local_file , default_file )
151+ # Restore backup if available
152+ if default_backup and default_backup .exists ():
153+ try :
154+ shutil .copy (default_backup , default_file )
155+ default_backup .unlink ()
156+ except Exception :
157+ pass
158+ # Re-raise the exception
159+ raise e
286160
287161 except Exception as e :
162+ # Log the error
288163 file_result .error = f"Error writing file: { str (e )} "
164+ if self .verbose :
165+ print (f"Error writing file { default_file } : { str (e )} " )
166+ import traceback
167+
168+ traceback .print_exc ()
169+
170+ def _prepare_merged_config (self , local_parsed , default_parsed ):
171+ """Prepare the merged configuration based on local and default settings.
172+
173+ Args:
174+ local_parsed: Dictionary of stanzas from local file
175+ default_parsed: Dictionary of stanzas from default file
176+
177+ Returns:
178+ Dictionary containing the merged configuration
179+ """
180+ merged_config = {}
181+
182+ # Start with all settings from default
183+ for stanza_name , settings in default_parsed .items ():
184+ merged_config [stanza_name ] = settings .copy ()
185+
186+ # Add or update settings from local based on merge mode
187+ for stanza_name , settings in local_parsed .items ():
188+ # If stanza doesn't exist in merged_config, add it
189+ if stanza_name not in merged_config :
190+ merged_config [stanza_name ] = {}
191+
192+ # Replace or merge settings based on mode
193+ if self .mode == MergeMode .REPLACE :
194+ # Replace entire stanza with local version
195+ merged_config [stanza_name ] = settings .copy ()
196+ else :
197+ # Update settings from local
198+ for setting_key , setting_value in settings .items ():
199+ merged_config [stanza_name ][setting_key ] = setting_value
200+
201+ return merged_config
202+
203+ def _write_merged_file (self , output_file , merged_config , local_content ):
204+ """Write the merged configuration to a file.
205+
206+ Args:
207+ output_file: Path to the output file
208+ merged_config: Dictionary containing the merged configuration
209+ local_content: String containing the local file content for multiline detection
210+ """
211+
212+ # List of settings to avoid duplicates
213+ written_settings = {}
214+
215+ with open (output_file , "w" , encoding = "utf-8" ) as f :
216+ # Process each stanza
217+ for stanza_name , settings in merged_config .items ():
218+ # Write stanza header
219+ f .write (f"[{ stanza_name } ]\n " )
220+ written_settings [stanza_name ] = set ()
221+
222+ # First directly process multiline values from the local file
223+ multiline_settings = set ()
224+
225+ # Directly find multiline settings in the local file
226+ stanza_pattern = rf"\[{ re .escape (stanza_name )} \](.*?)(?=\n\[|\Z)"
227+ stanza_matches = re .search (stanza_pattern , local_content , re .DOTALL )
228+
229+ if stanza_matches :
230+ stanza_content = stanza_matches .group (1 )
231+ # Find individual settings with continuation characters
232+ lines = stanza_content .split ("\n " )
233+ i = 0
234+
235+ while i < len (lines ):
236+ line = lines [i ].strip ()
237+
238+ # Check if this is a setting line
239+ if "=" in line and not line .startswith ("#" ):
240+ setting_key , setting_value = line .split ("=" , 1 )
241+ setting_key = setting_key .strip ()
242+
243+ # Only process settings that are in our merged config
244+ if setting_key in settings :
245+ # Check if this is potentially a multiline setting
246+ if "\\ " in setting_value :
247+ # This is a multiline setting
248+ multiline_lines = [line ]
249+
250+ # Collect all continuation lines
251+ j = i + 1
252+ current_line = line
253+
254+ while j < len (lines ) and (
255+ "\\ " in current_line or j == i + 1
256+ ):
257+ current_line = lines [j ].strip ()
258+ if not current_line .startswith (
259+ "["
260+ ): # Not a new stanza
261+ multiline_lines .append (current_line )
262+ j += 1
263+ else :
264+ break
265+
266+ if len (multiline_lines ) > 1 :
267+ # Write multiline setting
268+ multiline_settings .add (setting_key )
269+
270+ # Write setting line
271+ f .write (
272+ f"{ setting_key } = { setting_value .strip ()} \n "
273+ )
274+
275+ # Write continuation lines
276+ for ml_line in multiline_lines [1 :]:
277+ f .write (f"{ ml_line } \n " )
278+
279+ written_settings .setdefault (
280+ stanza_name , set ()
281+ ).add (setting_key )
282+ i = (
283+ j - 1
284+ ) # Update index to skip processed lines
285+
286+ i += 1
287+
288+ # Process regular settings
289+ for setting_key , setting_value in sorted (settings .items ()):
290+ if setting_key not in written_settings .get (stanza_name , set ()):
291+ f .write (f"{ setting_key } = { setting_value } \n " )
292+ written_settings .setdefault (stanza_name , set ()).add (setting_key )
293+
294+ # Add blank lines after each stanza
295+ f .write ("\n \n " )
289296
290297 def _merge_file (
291298 self , local_file : Path , target_file : Optional [Path ] = None
0 commit comments