Skip to content

Commit 922e9a3

Browse files
Merge pull request #342 from csrc-sdsu/feature/license-header-manager
Added python script to automatically propagate the MOLE software license in root directory to all MATLAB/Octave scripts and C++ files
2 parents 38dfcd4 + f8f11b2 commit 922e9a3

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

doc/utils/common/update_license.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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("\nDone.")
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

Comments
 (0)