817 lines
34 KiB
Python
817 lines
34 KiB
Python
import json
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import shutil
|
|
import json5
|
|
import glob
|
|
import fnmatch
|
|
import hashlib
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, fields, field, MISSING
|
|
|
|
# 1. 使用 @dataclass 装饰器,Python 会自动为你生成 __init__, __repr__ 等方法
|
|
@dataclass
|
|
class PackageType:
|
|
type: str
|
|
name: str
|
|
encryption: str
|
|
presets: list
|
|
platform: list
|
|
guid: str
|
|
APPDATA: str
|
|
OutputBaseFilename: str
|
|
Shortcut: str
|
|
MyAppName: str
|
|
PRODUCT:str = "ENV_2025_V6_S0"
|
|
hide:bool = False
|
|
|
|
# 可选字段 (有默认值,必须放在最后)
|
|
#Shortcut: str = ""
|
|
|
|
# 假设你未来想加一个可选的数组字段,必须这样写:
|
|
# ExtraPlugins: list = field(default_factory=list)
|
|
|
|
def getInnosetupParam(self) -> str:
|
|
sRel = ""
|
|
|
|
# 1. 检查加密方式是否为 Trial
|
|
if self.encryption == "Trial":
|
|
sRel += "/dTRIAL_YEAR "
|
|
|
|
# 2. 检查平台并附加对应的宏
|
|
if "ZWCAD" in self.platform:
|
|
sRel += "/dZWCAD "
|
|
if "ACAD" in self.platform:
|
|
sRel += "/dACAD "
|
|
if "ACAD" in self.platform and "ZWCAD" in self.platform:
|
|
sRel += "/dMERGER_CAD"
|
|
|
|
# 3. 遍历 presets 数组,添加元素宏
|
|
for preset in self.presets:
|
|
sRel += f"/d{preset} "
|
|
|
|
# 4. 将需要以键值对输出的变量集中处理
|
|
kv_vars = {
|
|
"guid": self.guid,
|
|
"APPDATA": self.APPDATA,
|
|
"OutputBaseFilename": self.OutputBaseFilename,
|
|
"Shortcut": self.Shortcut,
|
|
"MyAppName": self.MyAppName,
|
|
"PRODUCT": self.PRODUCT
|
|
}
|
|
|
|
for key, value in kv_vars.items():
|
|
# 只有当值不为空时才追加(可根据您的实际业务需求调整是否容许空值)
|
|
if value:
|
|
sRel += f"/d{key}=\"{value}\" "
|
|
else:
|
|
print("❌ {key} 的值为空!")
|
|
|
|
# 返回结果,并使用 strip() 去掉字符串最末尾的一个多余空格,使其更规范
|
|
return sRel.strip()
|
|
|
|
def display(self):
|
|
print(f"=== {self.type} ===")
|
|
# dataclass 仍然可以通过 __dict__ 遍历所有属性
|
|
for key, value in self.__dict__.items():
|
|
print(f" {key.ljust(20)}: {value}")
|
|
|
|
class PackageParser:
|
|
def __init__(self, raw_data: list):
|
|
self.raw_dict = {item["type"]: item for item in raw_data}
|
|
self.resolved_cache = {}
|
|
|
|
# 提取所有定义的字段名(用于过滤 JSON 中多余的垃圾数据)
|
|
self.all_fields = [f.name for f in fields(PackageType)]
|
|
|
|
# 核心魔法:只提取没有默认值的字段作为必填校验项
|
|
self.required_fields = [
|
|
f.name for f in fields(PackageType)
|
|
if f.default is MISSING and f.default_factory is MISSING
|
|
]
|
|
|
|
def parse_all(self) -> list:
|
|
instances = []
|
|
error_logs = []
|
|
|
|
for pkg_type in self.raw_dict.keys():
|
|
resolved_data = self._resolve_inheritance(pkg_type)
|
|
|
|
# 只用 required_fields 进行缺失检查
|
|
missing_fields = [field for field in self.required_fields if field not in resolved_data]
|
|
|
|
if missing_fields:
|
|
error_logs.append(f"错误: 节点 [{pkg_type}] 缺失必填字段: {', '.join(missing_fields)}")
|
|
else:
|
|
# 使用 all_fields 进行过滤,防止把 JSON 里的无关字段塞进 dataclass 报错
|
|
filtered_data = {k: v for k, v in resolved_data.items() if k in self.all_fields}
|
|
|
|
# 解包传入。如果 JSON 里没有可选字段,这里的 filtered_data 就不包含它,
|
|
# dataclass 的 __init__ 会自动使用我们在类里定义的默认值。
|
|
instances.append(PackageType(**filtered_data))
|
|
|
|
if error_logs:
|
|
print("❌ 配置文件解析失败,发现以下必填字段缺失:")
|
|
for error in error_logs:
|
|
print(" -", error)
|
|
return []
|
|
|
|
return instances
|
|
|
|
def _resolve_inheritance(self, pkg_type: str) -> dict:
|
|
# (这里的继承逻辑完全保持不变,省略以突出重点)
|
|
if pkg_type in self.resolved_cache:
|
|
return self.resolved_cache[pkg_type]
|
|
|
|
if pkg_type not in self.raw_dict:
|
|
raise ValueError(f"找不到定义的继承基类: {pkg_type}")
|
|
|
|
raw_node = self.raw_dict[pkg_type]
|
|
merged_data = {}
|
|
|
|
if "inherits" in raw_node:
|
|
for base_type in raw_node["inherits"]:
|
|
base_resolved = self._resolve_inheritance(base_type)
|
|
for k, v in base_resolved.items():
|
|
if k == "inherits":
|
|
continue
|
|
if isinstance(v, list):
|
|
if k not in merged_data:
|
|
merged_data[k] = []
|
|
for item in v:
|
|
if item not in merged_data[k]:
|
|
merged_data[k].append(item)
|
|
else:
|
|
merged_data[k] = v
|
|
|
|
for k, v in raw_node.items():
|
|
if k == "inherits":
|
|
continue
|
|
if isinstance(v, list):
|
|
if k not in merged_data:
|
|
merged_data[k] = []
|
|
for item in v:
|
|
if item not in merged_data[k]:
|
|
merged_data[k].append(item)
|
|
else:
|
|
merged_data[k] = v
|
|
|
|
self.resolved_cache[pkg_type] = merged_data
|
|
return merged_data
|
|
|
|
def get_all_presets_union(packages: list) -> list:
|
|
"""
|
|
接收一个 PackageType 列表,返回所有 presets 的并集列表(自动去重并保持顺序)
|
|
"""
|
|
# 收集所有的 preset
|
|
all_presets = []
|
|
for pkg in packages:
|
|
all_presets.extend(pkg.presets)
|
|
|
|
# 利用 dict.fromkeys() 去重,同时利用 Python 3.7+ 字典保持插入顺序的特性
|
|
union_presets = list(dict.fromkeys(all_presets))
|
|
|
|
return union_presets
|
|
|
|
def select_packages(packages: list) -> list:
|
|
"""
|
|
在控制台显示非隐藏的 PackageType 列表,并让用户通过输入索引进行多选。
|
|
"""
|
|
# 1. 过滤掉 hide 为 True 的对象
|
|
visible_packages = [pkg for pkg in packages if getattr(pkg, 'hide', False) == False]
|
|
|
|
if not visible_packages:
|
|
print("没有可供显示的打包类型。")
|
|
return []
|
|
|
|
# 2. 检查是否有命令行参数传入
|
|
# sys.argv[0] 是脚本名称本身,sys.argv[1:] 是传入的参数列表
|
|
cli_args = sys.argv[1:]
|
|
|
|
if cli_args:
|
|
unique_indices = []
|
|
for idx_str in cli_args:
|
|
try:
|
|
idx = int(idx_str)
|
|
# 检查索引是否越界
|
|
if 0 <= idx < len(visible_packages):
|
|
if idx not in unique_indices:
|
|
unique_indices.append(idx)
|
|
else:
|
|
print(f"⚠️ 致命错误: 命令行传入的序号 '{idx}' 超出范围。")
|
|
sys.exit(1) # 命令行参数错误时,直接退出程序
|
|
except ValueError:
|
|
print(f"⚠️ 致命错误: 命令行参数 '{idx_str}' 不是有效的数字。")
|
|
sys.exit(1)
|
|
|
|
# 解析成功,直接返回结果
|
|
selected_packages = [visible_packages[i] for i in unique_indices]
|
|
print("✅ 检测到命令行参数,已自动选择:")
|
|
for p in selected_packages:
|
|
print(f" -> {p.name}")
|
|
return selected_packages
|
|
|
|
# ==========================================
|
|
# 3. 如果没有命令行参数,则进入交互模式
|
|
# ==========================================
|
|
print("\n请选择要执行的打包任务(输入数字序号,多选请用空格隔开,例如: 0 2 3):")
|
|
print("-" * 50)
|
|
for i, pkg in enumerate(visible_packages):
|
|
print(f"[{i}] {pkg.name} (Type: {pkg.type})")
|
|
print("-" * 50)
|
|
|
|
# 3. 循环等待并处理用户输入
|
|
while True:
|
|
user_input = input("请输入您的选择: ").strip()
|
|
|
|
if not user_input:
|
|
print("输入不能为空,请重新输入。")
|
|
continue
|
|
|
|
selected_indices_str = user_input.split()
|
|
unique_indices = [] # 用来存放去重后的合法数字索引
|
|
has_error = False
|
|
|
|
for idx_str in selected_indices_str:
|
|
try:
|
|
idx = int(idx_str)
|
|
# 检查索引是否越界
|
|
if 0 <= idx < len(visible_packages):
|
|
# 在这里对索引进行去重,防止用户输入 "0 0 1"
|
|
if idx not in unique_indices:
|
|
unique_indices.append(idx)
|
|
else:
|
|
print(f"⚠️ 错误: 序号 '{idx}' 超出范围,请重新输入。")
|
|
has_error = True
|
|
break
|
|
except ValueError:
|
|
print(f"⚠️ 错误: '{idx_str}' 不是有效的数字,请重新输入。")
|
|
has_error = True
|
|
break
|
|
|
|
# 如果遍历过程中没有发生任何错误,并且用户确实做出了选择
|
|
if not has_error and unique_indices:
|
|
# 根据去重后的索引,生成对应的 PackageType 列表
|
|
selected_packages = [visible_packages[i] for i in unique_indices]
|
|
|
|
print("\n✅ 您已选择:")
|
|
for p in selected_packages:
|
|
print(f" -> {p.name}")
|
|
return selected_packages
|
|
|
|
def load_config(config_path="cmake_batch_build.json"):
|
|
if not os.path.exists(config_path):
|
|
print(f"错误: 找不到配置文件 {config_path}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
return json5.load(f)
|
|
except Exception as e:
|
|
print(f"解析配置文件失败,请检查语法: {e}")
|
|
sys.exit(1)
|
|
|
|
def resolve_variables(text, context_vars):
|
|
"""递归解析字符串中的 ${variable} 变量"""
|
|
while True:
|
|
replaced = text
|
|
for k, v in context_vars.items():
|
|
if isinstance(v, str):
|
|
replaced = replaced.replace(f"${{{k}}}", v)
|
|
if replaced == text:
|
|
break
|
|
text = replaced
|
|
return text
|
|
|
|
def buildStartApp(startapp_action, definitions):
|
|
#startapp_action = actions.get("compile-startApp", {})
|
|
ss = os.getcwd()
|
|
if startapp_action:
|
|
print("--- 正在编译 startApp 工程 ---")
|
|
for cp in startapp_action.get("copy", []):
|
|
form_list = cp.get("form", [])
|
|
to_list = cp.get("to", [])
|
|
for f_item in form_list:
|
|
for t_item in to_list:
|
|
f_resolved = resolve_variables(f_item, definitions)
|
|
t_resolved = resolve_variables(t_item, definitions)
|
|
print(f"执行预处理拷贝: 从 {f_resolved} 到 {t_resolved}")
|
|
try:
|
|
# 确保目标文件夹存在,否则 copy 会报错
|
|
os.makedirs(os.path.dirname(t_resolved), exist_ok=True)
|
|
|
|
# 使用 shutil.copy2 进行复制 (相当于 copy /y,且会保留文件的修改时间等元数据)
|
|
shutil.copy2(f_resolved, t_resolved)
|
|
print(f"✅ 拷贝成功: {t_resolved}")
|
|
except FileNotFoundError:
|
|
print(f"❌ 拷贝失败: 找不到源文件 {f_resolved}")
|
|
except Exception as e:
|
|
print(f"❌ 拷贝失败: {e}")
|
|
|
|
start_cmd = resolve_variables(startapp_action.get("cmd", ""), definitions)
|
|
print(f"执行 MSBuild: {start_cmd}")
|
|
build_res = subprocess.run(start_cmd, shell=True)
|
|
if build_res.returncode == 0:
|
|
print(f"✅ startAPP 编译成功。")
|
|
if os.path.exists("startApp.log"):
|
|
try:
|
|
os.remove("startApp.log")
|
|
except Exception:
|
|
pass
|
|
|
|
def svnUpdate(update_action):
|
|
if update_action:
|
|
update_cmd = update_action.get("cmd", "svn update")
|
|
update_path = update_action.get("path", "../../")
|
|
print(f"--- 正在更新代码: {update_cmd} {update_path} ---")
|
|
subprocess.run(update_cmd + ' ' + update_path, shell=True)
|
|
|
|
def buildPresets(presets, definitions):
|
|
# 现在编译presets,先取出presets的目录,作为工作目录
|
|
work_dir = resolve_variables(definitions.get("work-dir", "."), definitions)
|
|
script_dir = os.getcwd()
|
|
|
|
failed_presets = []
|
|
|
|
print(f"\n--- 开始编译 CMake Presets,目标 Presets: {presets} ---")
|
|
for preset in presets:
|
|
print(f"\n>>> 正在处理 Preset: {preset}")
|
|
|
|
print(f"==================================================")
|
|
print(f" [1/2] 正在配置 CMake Preset: {preset}")
|
|
print(f"==================================================")
|
|
|
|
configure_cmd = f'cd /d "{work_dir}" && cmake --preset {preset}'
|
|
res = subprocess.run(configure_cmd, shell=True)
|
|
|
|
if res.returncode != 0:
|
|
print(f"❌ 错误: Preset [{preset}] 的 Configure (配置) 阶段失败!整个脚本终止。")
|
|
sys.exit(res.returncode)
|
|
|
|
print(f"\n==================================================")
|
|
print(f" [2/2] 正在编译 CMake Preset: {preset}")
|
|
print(f"==================================================")
|
|
|
|
log_filename = f"{preset}.log"
|
|
log_path = os.path.join(script_dir, log_filename)
|
|
|
|
build_cmd = f'cd /d "{work_dir}" && cmake --build --preset {preset} --config release -j 8 -- /flp1:logfile="{log_path}";errorsonly;Verbosity:quiet'
|
|
build_res = subprocess.run(build_cmd, shell=True)
|
|
|
|
if build_res.returncode == 0:
|
|
print(f"✅ Preset [{preset}] 编译成功。")
|
|
if os.path.exists(log_path):
|
|
try:
|
|
os.remove(log_path)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
print(f"⚠️ 警告: Preset [{preset}] 的 Build (编译) 阶段出现错误 (返回码: {build_res.returncode})。")
|
|
print(f"错误日志已保存至 {log_path},继续执行下一个 preset...")
|
|
failed_presets.append(preset)
|
|
|
|
if failed_presets:
|
|
print("\n" + "="*50)
|
|
print(f"❌ 致命错误: 以下 Preset 编译失败: {failed_presets}")
|
|
print("由于存在未成功编译的工程,为了防止产出损坏的安装包,已终止后续的拷贝和打包操作。")
|
|
print("请检查对应的 .log 日志文件修复代码,然后重新运行本脚本。")
|
|
print("="*50 + "\n")
|
|
sys.exit(1)
|
|
|
|
def calculate_files_sha1(file_paths: list) -> str:
|
|
"""
|
|
计算多个文件的整体 SHA1 值。
|
|
为保证每次计算结果一致,会对文件路径进行排序。
|
|
"""
|
|
if not file_paths:
|
|
return ""
|
|
|
|
sha1 = hashlib.sha1()
|
|
|
|
# 排序保证即使文件遍历顺序不同,计算出的哈希也一致
|
|
for file_path in sorted(file_paths):
|
|
if not os.path.isfile(file_path):
|
|
continue
|
|
|
|
# 记录文件名本身的信息也可以防止“同内容但改了文件名”的情况,
|
|
# 如果你只关心内容,可以把这行注释掉。
|
|
sha1.update(os.path.basename(file_path).encode('utf-8'))
|
|
|
|
# 分块读取文件内容,防止大文件撑爆内存
|
|
with open(file_path, 'rb') as f:
|
|
while chunk := f.read(8192 * 1024): # 每次读取 8MB
|
|
sha1.update(chunk)
|
|
|
|
return sha1.hexdigest()
|
|
|
|
def process_compression_tasks(compress_tasks: list, definitions: dict):
|
|
"""
|
|
处理 JSON 中定义的压缩任务
|
|
:param compress_tasks: json 中的 "compress_huge_files" 数组
|
|
:param definitions: 传递给 resolve_variables 的变量字典
|
|
"""
|
|
for task in compress_tasks:
|
|
# 1. 解析所有路径中的变量
|
|
cmd_7za = os.path.abspath(os.path.normpath(task.get("7za", "7za.exe")))
|
|
compress_file = os.path.abspath(os.path.normpath(resolve_variables(task["compress_file"], definitions)))
|
|
sh1_flag_file = os.path.abspath(os.path.normpath(resolve_variables(task["sh1_flag_file"], definitions)))
|
|
|
|
raw_zip_files = [resolve_variables(f, definitions) for f in task["zip_files"]]
|
|
|
|
# 2. 使用 glob 展开通配符(比如 *.*),找到所有真实存在的文件
|
|
actual_files = []
|
|
for pattern in raw_zip_files:
|
|
# os.path.normpath 会把路径标准化,glob 才能更好地在 Windows 下工作
|
|
pattern = os.path.normpath(pattern)
|
|
matched = glob.glob(pattern)
|
|
actual_files.extend([f for f in matched if os.path.isfile(f)])
|
|
|
|
# 去重(防止通配符和精确路径重复匹配同一文件)
|
|
actual_files = list(set(actual_files))
|
|
|
|
# ==========================================
|
|
# 核心修复:剔除输出文件,防止“自我包含”
|
|
# 使用 os.path.abspath 和 normcase 确保在 Windows 下路径比对准确(忽略大小写和正反斜杠差异)
|
|
# ==========================================
|
|
abs_compress_file = os.path.normcase(os.path.abspath(compress_file))
|
|
abs_sh1_file = os.path.normcase(os.path.abspath(sh1_flag_file))
|
|
|
|
safe_actual_files = []
|
|
for f in actual_files:
|
|
abs_f = os.path.normcase(os.path.abspath(f))
|
|
# 如果抓取到的文件是我们将要生成的 7z 或 txt,直接跳过
|
|
if abs_f == abs_compress_file or abs_f == abs_sh1_file:
|
|
continue
|
|
safe_actual_files.append(f)
|
|
|
|
actual_files = safe_actual_files
|
|
# ==========================================
|
|
|
|
if not actual_files:
|
|
print(f"⚠️ 警告: 没有找到任何文件可以压缩,匹配模式为: {raw_zip_files}")
|
|
continue
|
|
|
|
# ==========================================
|
|
# 新增:严格校验所有的文件是否在同一个目录下
|
|
# 使用 os.path.abspath 防止相对路径和绝对路径混用导致判断失误
|
|
# ==========================================
|
|
parent_dirs = set(os.path.dirname(os.path.abspath(f)) for f in actual_files)
|
|
|
|
if len(parent_dirs) > 1:
|
|
print(f"❌ 致命错误: 待压缩的文件分布在不同的目录下!")
|
|
for d in parent_dirs:
|
|
print(f" - 检测到目录: {d}")
|
|
print(f"规则要求 zip_files 里的文件必须在同一个目录。当前任务终止。")
|
|
|
|
# 这里建议直接抛出异常或者 sys.exit(1) 中断整个打包流程,因为这是一个配置错误
|
|
raise ValueError("压缩失败:被压缩的文件未处于同一目录。")
|
|
|
|
# 校验通过,拿到这个唯一的基准目录
|
|
base_dir = list(parent_dirs)[0]
|
|
|
|
# 3. 计算当前真实文件的整体 SHA1
|
|
print(f"\n正在计算 {len(actual_files)} 个文件的联合 SHA1...")
|
|
current_sha1 = calculate_files_sha1(actual_files)
|
|
print(f"当前 SHA1: {current_sha1}")
|
|
|
|
# 4. 判断是否需要重新压缩
|
|
need_compress = False
|
|
|
|
if not os.path.exists(compress_file):
|
|
print("=> 压缩文件不存在,需要重新压缩。")
|
|
need_compress = True
|
|
elif not os.path.exists(sh1_flag_file):
|
|
print("=> SHA1 标志文件不存在,需要重新压缩。")
|
|
need_compress = True
|
|
else:
|
|
# 读取旧的 SHA1 值进行比对
|
|
try:
|
|
with open(sh1_flag_file, "r", encoding="utf-8") as f:
|
|
saved_sha1 = f.read().strip()
|
|
if saved_sha1 != current_sha1:
|
|
print(f"=> SHA1 值不匹配 (旧: {saved_sha1}),文件已发生变动,需要重新压缩。")
|
|
need_compress = True
|
|
else:
|
|
print(f"✅ 文件未发生变动且压缩包已存在,跳过压缩: {compress_file}")
|
|
except Exception as e:
|
|
print(f"=> 读取标志文件失败 ({e}),安全起见进行重新压缩。")
|
|
need_compress = True
|
|
|
|
# 5. 执行压缩
|
|
if need_compress:
|
|
# 为了防止 7za.exe a 模式下“把新旧文件混进同一个压缩包”,先删除旧包
|
|
if os.path.exists(compress_file):
|
|
try:
|
|
os.remove(compress_file)
|
|
except OSError as e:
|
|
print(f"删除旧压缩包失败: {e}")
|
|
|
|
# 确保压缩文件和标志文件的输出目录存在
|
|
os.makedirs(os.path.dirname(compress_file), exist_ok=True)
|
|
os.makedirs(os.path.dirname(sh1_flag_file), exist_ok=True)
|
|
|
|
file_names_only = [os.path.basename(f) for f in actual_files]
|
|
|
|
# 组装命令行: 7za.exe a "C:/.../hugeDb.7z" Aec3DMep.db shmodel.db
|
|
command = [cmd_7za, "a", compress_file] + file_names_only
|
|
|
|
print(f"正在执行压缩 (工作目录: {base_dir})...")
|
|
# print("命令:", " ".join(command))
|
|
|
|
# 运行压缩
|
|
result = subprocess.run(command, cwd=base_dir, shell=False)
|
|
|
|
if result.returncode == 0:
|
|
# 6. 压缩成功,写入最新的 SHA1 标志
|
|
with open(sh1_flag_file, "w", encoding="utf-8") as f:
|
|
f.write(current_sha1)
|
|
print("✅ 压缩完成,已更新 SHA1 标志文件。")
|
|
else:
|
|
print(f"❌ 压缩失败,7za 返回错误码: {result.returncode}")
|
|
# 压缩失败时清理残次品,防止下次误认为压缩成功
|
|
if os.path.exists(compress_file):
|
|
os.remove(compress_file)
|
|
|
|
def copy_preset(copy_preset_dict, presets, definitions):
|
|
"""
|
|
处理针对不同编译 Preset 的批量分发拷贝操作
|
|
:param copy_preset_dict: json 中的 "copy_preset" 对象字典
|
|
:param presets: 类似于 ["R200", "R210"] 的预设数组
|
|
:param definitions: 环境变量字典,用于 resolve_variables 解析
|
|
"""
|
|
form_list = copy_preset_dict.get("form", [])
|
|
to_list = copy_preset_dict.get("to", [])
|
|
|
|
if not form_list or not to_list or not presets:
|
|
return
|
|
|
|
print(f"\n🚀 开始执行 copy_preset 批量分发,目标平台: {presets}")
|
|
|
|
# 外层循环:遍历每一个 Preset
|
|
for preset in presets:
|
|
|
|
# 遍历每一个目的地 (to)
|
|
for t_item in to_list:
|
|
# 解析目标根路径并拼接 preset
|
|
t_resolved = resolve_variables(t_item, definitions)
|
|
dst_dir = os.path.normpath(os.path.join(t_resolved, preset))
|
|
|
|
# 确保目标目录已存在
|
|
os.makedirs(dst_dir, exist_ok=True)
|
|
|
|
# 遍历每一个来源 (form)
|
|
for f_item in form_list:
|
|
f_resolved = os.path.normpath(resolve_variables(f_item, definitions))
|
|
|
|
# 情况 1: form 元素是一个明确的目录
|
|
if os.path.isdir(f_resolved):
|
|
# 行为:源目录也要加上 preset
|
|
src_dir = os.path.join(f_resolved, 'Release-x64-' + preset)
|
|
|
|
if os.path.isdir(src_dir):
|
|
print(f"📦 [目录分发] {src_dir} -> {dst_dir}")
|
|
# shutil.copytree 的 dirs_exist_ok=True (Python 3.8+)
|
|
# 能够完美实现“把目录里的所有文件(包括子目录)强行覆盖合并到目标目录”
|
|
shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
|
|
else:
|
|
print(f"⚠️ 警告: 找不到对应的 preset 源目录,跳过: {src_dir}")
|
|
|
|
# 情况 2: form 元素是文件或通配符 (*.dll, *.* 等)
|
|
else:
|
|
# 行为:直接使用 glob 匹配,解析出所有具体文件,无需加上 preset
|
|
matched_files = glob.glob(f_resolved)
|
|
|
|
if not matched_files:
|
|
print(f"⚠️ 警告: 通配符/文件未匹配到任何内容: {f_resolved}")
|
|
continue
|
|
|
|
for f_path in matched_files:
|
|
if os.path.isfile(f_path):
|
|
print(f"📄 [文件分发] {f_path} -> {dst_dir}")
|
|
# 拷贝单文件到目标目录 (如果存在同名文件会自动覆盖)
|
|
shutil.copy2(f_path, dst_dir)
|
|
|
|
print("✅ copy_preset 批量分发完成。")
|
|
|
|
def process_general_copy(copy_actions, definitions):
|
|
"""
|
|
处理普通的批量 copy 行为
|
|
:param copy_actions: json 中的 "copy" 数组
|
|
:param definitions: 用于 resolve_variables 解析的环境变量字典
|
|
"""
|
|
|
|
# --- 辅助匹配函数(支持相对路径匹配和层级匹配) ---
|
|
def is_match(rel_path, patterns):
|
|
if not patterns:
|
|
return False
|
|
|
|
rel_path_str = str(rel_path).replace('\\', '/')
|
|
parts = rel_path.parts
|
|
|
|
for pattern in patterns:
|
|
pattern = pattern.replace('\\', '/')
|
|
# 情况 A: 路径整体匹配
|
|
if fnmatch.fnmatch(rel_path_str, pattern):
|
|
return True
|
|
# 情况 B: 逐级文件名或目录名匹配
|
|
for part in parts:
|
|
if fnmatch.fnmatch(part, pattern):
|
|
return True
|
|
return False
|
|
|
|
print("\n🚀 开始执行普通 Copy 行为...")
|
|
|
|
for action in copy_actions:
|
|
form_list = action.get("form", [])
|
|
to_list = action.get("to", [])
|
|
# 注意 JSON 里键名是 "include" 和 "exclude"
|
|
includes = action.get("include", [])
|
|
excludes = action.get("exclude", [])
|
|
|
|
# m 循环: 遍历源路径
|
|
for f_item in form_list:
|
|
f_resolved = os.path.normpath(resolve_variables(f_item, definitions))
|
|
|
|
# 判断是以通配符结尾,还是纯目录
|
|
base_dir_str, file_pattern = os.path.split(f_resolved)
|
|
is_wildcard = ('*' in file_pattern or '?' in file_pattern)
|
|
|
|
if is_wildcard:
|
|
src_dir = Path(base_dir_str).resolve()
|
|
else:
|
|
src_dir = Path(f_resolved).resolve()
|
|
|
|
if not src_dir.exists() or not src_dir.is_dir():
|
|
print(f"⚠️ 源目录不存在或无效,跳过: {src_dir}")
|
|
continue
|
|
|
|
# n 循环: 遍历目标路径
|
|
for t_item in to_list:
|
|
t_resolved = os.path.normpath(resolve_variables(t_item, definitions))
|
|
dst_dir = Path(t_resolved).resolve()
|
|
|
|
# 根据是否为通配符,决定目标输出的基准路径
|
|
if is_wildcard:
|
|
# 通配符:直接 copy 到 dst_dir,不需要再建根目录
|
|
target_base = dst_dir
|
|
print(f"📂 [通配符拷贝] {src_dir} / {file_pattern} -> {target_base}")
|
|
else:
|
|
# 纯目录:在目标文件夹中创建一个和源目录同名的子目录 (例如 xxx/yyy/ddd)
|
|
target_base = dst_dir / src_dir.name
|
|
print(f"📦 [整个目录拷贝] {src_dir} -> {target_base}")
|
|
|
|
copy_count = 0
|
|
|
|
# 开始遍历源目录最深处的所有文件
|
|
for root, dirs, files in os.walk(src_dir):
|
|
for file_name in files:
|
|
file_path = Path(root) / file_name
|
|
rel_path = file_path.relative_to(src_dir)
|
|
|
|
# 1. 如果是通配符模式,文件自身必须先符合这个通配符规则
|
|
# (例如 *.* 就是全部通过,*.dll 只有 dll 通过)
|
|
if is_wildcard:
|
|
if not fnmatch.fnmatch(file_name, file_pattern):
|
|
continue
|
|
|
|
# 2. 匹配 include / exclude 规则
|
|
matched_include = is_match(rel_path, includes)
|
|
matched_exclude = is_match(rel_path, excludes)
|
|
|
|
# 3. 核心冲突解决逻辑 (白名单优先原则)
|
|
should_copy = True
|
|
|
|
if includes:
|
|
# 如果配置了白名单,只有在白名单里才 copy
|
|
if matched_include:
|
|
should_copy = True
|
|
else:
|
|
should_copy = False
|
|
else:
|
|
# 没有配置白名单,碰到黑名单就不 copy
|
|
if matched_exclude:
|
|
should_copy = False
|
|
|
|
# 最终覆盖:如果既满足 include 又满足 exclude,以 include 为准!
|
|
if matched_include:
|
|
should_copy = True
|
|
|
|
# 4. 执行文件拷贝
|
|
if should_copy:
|
|
target_path = target_base / rel_path
|
|
|
|
# 自动创建深层级的子目录
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(file_path, target_path)
|
|
copy_count += 1
|
|
|
|
print(f" └─ 完成,共拷贝了 {copy_count} 个文件。")
|
|
|
|
def package(innosetup, packages, definitions):
|
|
"""
|
|
调用 Inno Setup 命令行编译器 (ISCC) 为每个选择的 PackageType 进行打包
|
|
:param innosetup: json 中的 "innosetup" 对象字典
|
|
:param packages: 用户选择的 PackageType 实例列表
|
|
:param definitions: 环境变量字典 (用于 resolve_variables)
|
|
"""
|
|
if not innosetup or not packages:
|
|
print("⚠️ 没有提供打包配置或包列表为空,跳过打包。")
|
|
return
|
|
|
|
print("\n" + "="*50)
|
|
print("🚀 开始执行 Inno Setup 终极打包流程")
|
|
print("="*50)
|
|
|
|
iscc = innosetup.get("iscc")
|
|
iss = innosetup.get("iss")
|
|
|
|
base_cmd = f"\"{iscc}\" \"{iss}\""
|
|
|
|
success_count = 0
|
|
|
|
# 3. 循环遍历用户选择的每一个 PackageType
|
|
for pkg in packages:
|
|
print(f"\n📦 正在编译安装包: {pkg.name} (Type: {pkg.type})")
|
|
|
|
# 获取属于这个包特有的宏定义参数
|
|
# 注意:这里调用的方法名是我们在前面定义的 getInnosetpParam
|
|
pkg.PRODUCT = definitions.get("product")
|
|
params = pkg.getInnosetupParam()
|
|
|
|
# 拼接最终执行的完整命令
|
|
final_cmd = f'{base_cmd} {params}'
|
|
print(f"执行命令: {final_cmd}")
|
|
|
|
# 执行命令 (ISCC 这种带双引号包裹路径的复杂命令行,使用 shell=True 兼容性最好)
|
|
try:
|
|
result = subprocess.run(final_cmd, shell=True)
|
|
|
|
if result.returncode == 0:
|
|
print(f"✅ [{pkg.name}] 打包成功!")
|
|
success_count += 1
|
|
else:
|
|
print(f"❌ [{pkg.name}] 打包失败,ISCC 编译器返回了错误码: {result.returncode}")
|
|
# 根据你的业务需求,遇到失败可以选择 break 直接终止,或者 continue 继续打下一个包
|
|
|
|
except Exception as e:
|
|
print(f"❌ 致命错误:启动 ISCC 失败,请检查路径是否正确: {e}")
|
|
|
|
# 4. 打印最终结果统计
|
|
print("\n" + "="*50)
|
|
print(f"🎉 所有的打包任务已结束!总计: {len(packages)} 个,成功: {success_count} 个。")
|
|
print("="*50)
|
|
|
|
def main():
|
|
# 设置当前脚本的路径为工作目录
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
os.chdir(script_dir)
|
|
|
|
config = load_config("cmake_batch_build.json")
|
|
definitions = config.get("definitions", {})
|
|
actions = config.get("actions", {})
|
|
|
|
# 解析出所有的PackateType
|
|
parser = PackageParser(definitions.get("package-types"))
|
|
all_packages = parser.parse_all()
|
|
|
|
# 与用户交互,得到用户选择的packageType列表
|
|
g_packages = select_packages(all_packages)
|
|
|
|
# 得到需要编译的preset列表
|
|
g_presets = get_all_presets_union(g_packages)
|
|
if not g_presets:
|
|
print("没有选择打包类型。")
|
|
return
|
|
|
|
# 更新代码
|
|
svnUpdate(actions.get("update-svn", {}))
|
|
|
|
# 编译startApp工程
|
|
buildStartApp(actions.get("compile-startApp", {}), definitions)
|
|
|
|
# 编译presets
|
|
buildPresets(g_presets, definitions)
|
|
|
|
# 压缩大文件
|
|
process_compression_tasks(actions.get("compress_huge_files"), definitions)
|
|
|
|
#copy 打包文件
|
|
dest_dir = resolve_variables(definitions.get("dest_dir", ""), definitions)
|
|
if os.path.exists(dest_dir):
|
|
print(f"[*] 清空统一输出基础目录: {dest_dir}")
|
|
shutil.rmtree(dest_dir, ignore_errors=True)
|
|
|
|
# copy 编译生成物和依赖
|
|
copy_preset(actions.get("copy_preset"), g_presets, definitions)
|
|
|
|
# copy 其他需要打包的文件
|
|
process_general_copy(actions.get("copy"), definitions)
|
|
|
|
# 打包
|
|
package(actions.get("innosetup"), g_packages, definitions)
|
|
|
|
return
|
|
|
|
if __name__ == "__main__":
|
|
main() |