2525import re
2626import subprocess
2727import tempfile
28+ import fcntl
29+ import time
2830
2931LOG = logging .getLogger ()
3032logging .basicConfig (level = logging .INFO )
@@ -1679,58 +1681,125 @@ def preprocess_adoc_tables(
16791681 return result , fixes
16801682
16811683
1684+ class FileLock :
1685+ """Context manager for file locking to prevent concurrent modifications.
1686+
1687+ Uses fcntl-based advisory locks on Linux systems. Creates a .lock file
1688+ next to the target file for locking purposes.
1689+ """
1690+
1691+ def __init__ (
1692+ self , file_path : Path , timeout : int = 300 , check_interval : float = 0.1
1693+ ):
1694+ """Initialize the file lock.
1695+
1696+ Args:
1697+ file_path: Path to the file to lock
1698+ timeout: Maximum time in seconds to wait for lock (default: 300s = 5 min)
1699+ check_interval: Time in seconds between lock acquisition attempts (default: 0.1s)
1700+ """
1701+ self .file_path = file_path
1702+ self .lock_path = file_path .parent / f".{ file_path .name } .lock"
1703+ self .timeout = timeout
1704+ self .check_interval = check_interval
1705+ self .lock_file = None
1706+
1707+ def __enter__ (self ):
1708+ """Acquire the file lock."""
1709+ start_time = time .time ()
1710+ while True :
1711+ try :
1712+ # Open lock file (create if it doesn't exist)
1713+ self .lock_file = open (self .lock_path , "w" )
1714+ # Try to acquire exclusive lock (non-blocking)
1715+ fcntl .flock (self .lock_file .fileno (), fcntl .LOCK_EX | fcntl .LOCK_NB )
1716+ # Successfully acquired lock
1717+ LOG .debug (f"Acquired lock for { self .file_path } " )
1718+ return self
1719+ except (IOError , OSError ):
1720+ # Lock is held by another process
1721+ if time .time () - start_time > self .timeout :
1722+ if self .lock_file :
1723+ self .lock_file .close ()
1724+ raise TimeoutError (
1725+ f"Could not acquire lock for { self .file_path } after { self .timeout } s"
1726+ )
1727+ # Wait and retry
1728+ time .sleep (self .check_interval )
1729+
1730+ def __exit__ (self , exc_type , exc_val , exc_tb ):
1731+ """Release the file lock."""
1732+ if self .lock_file :
1733+ try :
1734+ fcntl .flock (self .lock_file .fileno (), fcntl .LOCK_UN )
1735+ self .lock_file .close ()
1736+ LOG .debug (f"Released lock for { self .file_path } " )
1737+ # Try to remove the lock file (best effort)
1738+ try :
1739+ self .lock_path .unlink ()
1740+ except Exception :
1741+ pass
1742+ except Exception as e :
1743+ LOG .warning (f"Error releasing lock for { self .file_path } : { e } " )
1744+
1745+
16821746def fix_adoc_file (file_path : Path ) -> list [str ]:
16831747 """Apply all AsciiDoc fixes to a source file and report changes.
16841748
16851749 This function reads an .adoc file, applies all preprocessing fixes,
16861750 writes the changes back to the file if any fixes were made, and
16871751 returns a list of descriptions of what was fixed.
16881752
1753+ Uses file locking to prevent concurrent modifications when multiple
1754+ workers are processing files in parallel.
1755+
16891756 Args:
16901757 file_path: Path to the .adoc file to fix
16911758
16921759 Returns:
16931760 List of fix descriptions (empty if no fixes were needed)
16941761 """
1695- try :
1696- with open (file_path , "r" , encoding = "utf-8" ) as f :
1697- original_content = f .read ()
1698- except Exception as e :
1699- LOG .error (f"Failed to read { file_path } : { e } " )
1700- return []
1762+ # Acquire exclusive lock before processing the file
1763+ with FileLock (file_path ):
1764+ try :
1765+ with open (file_path , "r" , encoding = "utf-8" ) as f :
1766+ original_content = f .read ()
1767+ except Exception as e :
1768+ LOG .error (f"Failed to read { file_path } : { e } " )
1769+ return []
17011770
1702- content = original_content
1703- all_fixes = []
1771+ content = original_content
1772+ all_fixes = []
17041773
1705- # Apply all preprocessing steps
1706- content , fixes = preprocess_adoc_link_brackets (content , file_path )
1707- all_fixes .extend (fixes )
1774+ # Apply all preprocessing steps
1775+ content , fixes = preprocess_adoc_link_brackets (content , file_path )
1776+ all_fixes .extend (fixes )
17081777
1709- content , fixes = preprocess_adoc_callout_numbering (content , file_path )
1710- all_fixes .extend (fixes )
1778+ content , fixes = preprocess_adoc_callout_numbering (content , file_path )
1779+ all_fixes .extend (fixes )
17111780
1712- content , fixes = preprocess_adoc_callout_placement (content , file_path )
1713- all_fixes .extend (fixes )
1781+ content , fixes = preprocess_adoc_callout_placement (content , file_path )
1782+ all_fixes .extend (fixes )
17141783
1715- content , fixes = preprocess_adoc_callouts (content , file_path )
1716- all_fixes .extend (fixes )
1784+ content , fixes = preprocess_adoc_callouts (content , file_path )
1785+ all_fixes .extend (fixes )
17171786
1718- content , fixes = preprocess_adoc_callout_spacing (content , file_path )
1719- all_fixes .extend (fixes )
1787+ content , fixes = preprocess_adoc_callout_spacing (content , file_path )
1788+ all_fixes .extend (fixes )
17201789
1721- content , fixes = preprocess_adoc_tables (content , file_path )
1722- all_fixes .extend (fixes )
1790+ content , fixes = preprocess_adoc_tables (content , file_path )
1791+ all_fixes .extend (fixes )
17231792
1724- # Only write back if changes were made
1725- if content != original_content :
1726- try :
1727- with open (file_path , "w" , encoding = "utf-8" ) as f :
1728- f .write (content )
1729- except Exception as e :
1730- LOG .error (f"Failed to write fixes to { file_path } : { e } " )
1731- return []
1793+ # Only write back if changes were made
1794+ if content != original_content :
1795+ try :
1796+ with open (file_path , "w" , encoding = "utf-8" ) as f :
1797+ f .write (content )
1798+ except Exception as e :
1799+ LOG .error (f"Failed to write fixes to { file_path } : { e } " )
1800+ return []
17321801
1733- return all_fixes
1802+ return all_fixes
17341803
17351804
17361805def fix_adoc_files_in_directory (base_dir : Path ) -> dict [Path , list [str ]]:
@@ -2589,6 +2658,8 @@ def convert(self, input_path: Path, output_path: Path) -> dict[Path, list[str]]:
25892658
25902659 LOG .info ("Successfully converted: %s -> %s" , input_path , output_path )
25912660
2661+ return fixes_by_file
2662+
25922663 except Exception as e :
25932664 LOG .error (
25942665 "Failed to convert: %s -> %s (%s)" , input_path , output_path , e
@@ -2873,7 +2944,8 @@ def convert(self, input_path: Path, output_path: Path) -> None:
28732944 all_fixes [file_path ] = fixes
28742945 except Exception as e :
28752946 failed_conversions .append ((str (input_path ), str (e )))
2876- LOG .error ("Continuing with next document after failure..." )
2947+ LOG .error ("Failed to convert %s: %s" , input_path , str (e ))
2948+ LOG .error ("Continuing with next document..." )
28772949
28782950 if args .relnotes_dir :
28792951 relnotes_converter = RelNotesConverter (attributes_file = args .attributes_file )
@@ -2891,7 +2963,8 @@ def convert(self, input_path: Path, output_path: Path) -> None:
28912963 all_fixes [file_path ] = fixes
28922964 except Exception as e :
28932965 failed_conversions .append ((str (input_path ), str (e )))
2894- LOG .error ("Continuing with next document after failure..." )
2966+ LOG .error ("Failed to convert %s: %s" , input_path , str (e ))
2967+ LOG .error ("Continuing with next document..." )
28952968
28962969 # Print summary
28972970 LOG .info ("\n " + "=" * 80 )
0 commit comments