1+ #!/usr/bin/env python3
2+
3+ from pathlib import Path
4+ import re
5+ import sys
6+
7+
8+ SUPPORTED_EXTENSIONS = {".m" , ".cpp" , ".hpp" , ".h" }
9+
10+
11+ def get_comment_prefix (file_path : Path ) -> str :
12+ ext = file_path .suffix .lower ()
13+ if ext == ".m" :
14+ return "%"
15+ if ext in {".cpp" , ".hpp" , ".h" }:
16+ return "//"
17+ raise ValueError (f"Unsupported file type: { file_path } " )
18+
19+
20+ def normalize_line (line : str , comment_prefix : str ) -> str :
21+ """
22+ Normalize a line for comparison:
23+ - strip newline
24+ - remove leading comment marker
25+ - trim whitespace
26+ """
27+ line = line .rstrip ("\n " ).strip ()
28+ if line .startswith (comment_prefix ):
29+ line = line [len (comment_prefix ):].strip ()
30+ return line
31+
32+
33+ def read_license_lines (input_file : Path ):
34+ with input_file .open ("r" , encoding = "utf-8" ) as f :
35+ raw_lines = f .readlines ()
36+ return [line .rstrip ("\n " ) for line in raw_lines ]
37+
38+
39+ def make_license_block (license_lines , comment_prefix : str , add_trailing_blank_line : bool = True ):
40+ """
41+ Build the LICENSE section.
42+
43+ add_trailing_blank_line=True -> use when inserting at top
44+ add_trailing_blank_line=False -> use when replacing existing block
45+ """
46+ block = [f"{ comment_prefix } LICENSE:" ]
47+ for line in license_lines :
48+ if line .strip () == "" :
49+ block .append (comment_prefix )
50+ else :
51+ block .append (f"{ comment_prefix } { line } " )
52+
53+ text = "\n " .join (block ) + "\n "
54+ if add_trailing_blank_line :
55+ text += "\n "
56+ return text
57+
58+
59+ def find_top_license_block (lines , comment_prefix : str ):
60+ """
61+ Find a LICENSE block only at the top of the file.
62+
63+ Allowed before LICENSE:
64+ - blank lines
65+
66+ The LICENSE block starts at:
67+ <comment_prefix> LICENSE:
68+
69+ and continues through consecutive comment lines / blank lines until:
70+ - a non-comment, non-blank line is reached.
71+
72+ Returns:
73+ (start_idx, end_idx, normalized_lines)
74+ where end_idx is exclusive.
75+ """
76+ license_tag_pattern = re .compile (
77+ rf'^\s*{ re .escape (comment_prefix )} \s*LICENSE\s*:\s*$' ,
78+ re .IGNORECASE
79+ )
80+
81+ i = 0
82+
83+ # Skip leading blank lines only
84+ while i < len (lines ) and lines [i ].strip () == "" :
85+ i += 1
86+
87+ # If first meaningful line is not LICENSE, treat as no top license block
88+ if i >= len (lines ) or not license_tag_pattern .match (lines [i ]):
89+ return None , None , None
90+
91+ start = i
92+ end = i + 1
93+
94+ while end < len (lines ):
95+ stripped = lines [end ].strip ()
96+ if stripped == "" or stripped .startswith (comment_prefix ):
97+ end += 1
98+ else :
99+ break
100+
101+ existing_lines = lines [start :end ]
102+
103+ # Remove trailing blank spacer lines from comparison
104+ while existing_lines and normalize_line (existing_lines [- 1 ], comment_prefix ) == "" :
105+ existing_lines .pop ()
106+ end -= 1
107+
108+ normalized = [normalize_line (x , comment_prefix ) for x in existing_lines ]
109+ return start , end , normalized
110+
111+
112+ def process_file (file_path : Path , desired_license_lines ):
113+ comment_prefix = get_comment_prefix (file_path )
114+
115+ with file_path .open ("r" , encoding = "utf-8" ) as f :
116+ original_lines = f .readlines ()
117+
118+ desired_normalized = ["LICENSE:" ] + [line .strip () for line in desired_license_lines ]
119+
120+ desired_block_for_insert = make_license_block (
121+ desired_license_lines , comment_prefix , add_trailing_blank_line = True
122+ )
123+ desired_block_for_update = make_license_block (
124+ desired_license_lines , comment_prefix , add_trailing_blank_line = False
125+ )
126+
127+ start , end , existing_normalized = find_top_license_block (original_lines , comment_prefix )
128+
129+ # Same LICENSE block already present at top
130+ if existing_normalized is not None and existing_normalized == desired_normalized :
131+ print (f"Skipped (same LICENSE header): { file_path } " )
132+ return "skipped_same"
133+
134+ # Different LICENSE block exists at top -> replace it
135+ if existing_normalized is not None :
136+ new_lines = original_lines [:start ] + [desired_block_for_update ] + original_lines [end :]
137+ with file_path .open ("w" , encoding = "utf-8" ) as f :
138+ f .writelines (new_lines )
139+ print (f"Updated LICENSE header: { file_path } " )
140+ return "updated"
141+
142+ # No top LICENSE block found -> insert at top
143+ new_lines = [desired_block_for_insert ] + original_lines
144+ with file_path .open ("w" , encoding = "utf-8" ) as f :
145+ f .writelines (new_lines )
146+
147+ print (f"Inserted LICENSE header: { file_path } " )
148+ return "inserted"
149+
150+
151+ def update_license_headers (license_header_file , target_folder ):
152+ license_header_path = Path (license_header_file )
153+ folder_path = Path (target_folder )
154+
155+ if not license_header_path .is_file ():
156+ raise FileNotFoundError (f"License header file not found: { license_header_path } " )
157+
158+ if not folder_path .is_dir ():
159+ raise NotADirectoryError (f"Target folder not found: { folder_path } " )
160+
161+ desired_license_lines = read_license_lines (license_header_path )
162+
163+ files_to_process = []
164+ for ext in SUPPORTED_EXTENSIONS :
165+ files_to_process .extend (folder_path .rglob (f"*{ ext } " ))
166+
167+ files_to_process = sorted (files_to_process )
168+
169+ if not files_to_process :
170+ print ("No supported source files found." )
171+ return
172+
173+ summary = {
174+ "inserted" : 0 ,
175+ "updated" : 0 ,
176+ "skipped_same" : 0 ,
177+ }
178+
179+ for file_path in files_to_process :
180+ result = process_file (file_path , desired_license_lines )
181+ summary [result ] += 1
182+
183+ print ("\n Done." )
184+ print (f"Inserted: { summary ['inserted' ]} " )
185+ print (f"Updated : { summary ['updated' ]} " )
186+ print (f"Skipped (same header): { summary ['skipped_same' ]} " )
187+
188+
189+ if __name__ == "__main__" :
190+ if len (sys .argv ) != 3 :
191+ print ("Usage: python update_license.py <license_header_file> <target_folder>" )
192+ sys .exit (1 )
193+
194+ license_header_file = sys .argv [1 ]
195+ target_folder = sys .argv [2 ]
196+
197+ update_license_headers (license_header_file , target_folder )
0 commit comments