Files
2026-06-25 17:41:06 +08:00

367 lines
13 KiB
Python

import configparser
import subprocess
import shutil
import argparse
import requests
from pathlib import Path
import time
import os
import sys
sys.path.append(str(Path(__file__).resolve().parent.parent))
from build import get_settings
def download_file(url, dest):
"""下载文件到指定路径"""
try:
r = requests.get(url, stream=True)
r.raise_for_status()
with open(dest, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True
except Exception as e:
print(f"下载失败: {e}")
return False
def load_config(config_path):
"""加载配置文件"""
return get_settings()
config = configparser.ConfigParser()
try:
# 指定 UTF-8 编码读取配置文件
config.read(config_path, encoding='utf-8')
if not config.sections(): # 检查是否成功读取到配置项
raise ValueError(f"配置文件 {config_path} 为空或格式错误")
return config
except FileNotFoundError:
raise FileNotFoundError(f"配置文件 {config_path} 不存在")
except UnicodeDecodeError:
# 尝试其他常见编码
try:
config.read(config_path, encoding='gbk')
if not config.sections():
raise ValueError(f"配置文件 {config_path} 为空或格式错误")
return config
except Exception as e:
raise ValueError(f"配置文件 {config_path} 编码错误: {str(e)}")
except Exception as e:
raise ValueError(f"加载配置文件 {config_path} 失败: {str(e)}")
def build_nuitka_command(config, args, app_name):
"""构造Nuitka编译命令"""
cmd = []
# 基本选项
if compiler := args.compiler or config.nuitka.compiler:
cmd.append(f"--{compiler}")
if args.show_progress or config.nuitka.show_progress:
cmd.append("--show-progress")
if args.standalone or config.nuitka.standalone:
cmd.append("--standalone")
if args.onefile or config.nuitka.onefile:
cmd.append("--onefile")
if config.nuitka.remove_output:
cmd.append("--remove-output")
if args.disable_console or config.nuitka.disable_console:
cmd.append("--windows-disable-console")
# 版本信息
if product_version := config.version.version:
cmd.append(f"--product-version={product_version}")
if file_version := config.version.version_str:
cmd.append(f"--file-version={file_version}")
if company_name := config.version.author:
cmd.append(f"--company-name={company_name}")
if product_name := (config.version.alias or config.version.name):
cmd.append(f"--product-name={product_name}")
cmd.append(f"--output-filename={app_name}")
# 添加新的编译选项
if config.nuitka.get('follow_imports', True):
cmd.append("--follow-imports")
if lto := config.nuitka.lto:
cmd.append(f"--lto={lto}")
if jobs := args.jobs or config.nuitka.jobs:
cmd.append(f"--jobs={jobs}")
# 插件处理
if plugins := config.nuitka.enable_plugin:
for plugin in plugins.split(','):
cmd.append(f"--enable-plugin={plugin.strip()}")
# 包和模块包含
if packages := config.nuitka.include_package:
for package in packages.split(','):
cmd.append(f"--include-package={package.strip()}")
if modules := config.nuitka.include_module:
for module in modules.split(','):
cmd.append(f"--include-module={module.strip()}")
# 不跟随的导入
if nofollow := config.nuitka.nofollow_import_to:
for item in nofollow.split(','):
cmd.append(f"--nofollow-import-to={item.strip()}")
# 数据文件排除
if noinclude_data := config.nuitka.noinclude_data_files:
for pattern in noinclude_data.split(','):
cmd.append(f"--noinclude-data-files={pattern.strip()}")
# Python标志
if python_flags := config.nuitka.python_flag:
for flag in python_flags.split(','):
cmd.append(f"--python-flag={flag.strip()}")
# 图标处理
icon_source = args.icon or config.build.icon_file
if icon_source:
ico_path = 'favicon.ico'
if 'ico' not in icon_source:
from tranform_to_ico import convert_png_to_ico
convert_png_to_ico(icon_source, ico_path, sizes=[(256, 256)], sharpen=False)
else:
ico_path = icon_source
if ico_path.startswith(('http://', 'https://')):
local_icon = Path("download.ico")
if download_file(ico_path, local_icon):
cmd.append(f"--windows-icon-from-ico={local_icon}")
else:
cmd.append(f"--windows-icon-from-ico={ico_path}")
# 额外选项
if extra_options := config.nuitka.extra_options:
if isinstance(extra_options, str):
extra_options = extra_options.split(',')
cmd.extend(extra_options)
# 输出目录
output_dir = args.output or config.build.output_dir or 'dist'
cmd.append(f"--output-dir={output_dir}")
python_exe = sys.executable
cmd = [python_exe, "-m", "nuitka"] + list(set(cmd))
# 源文件
source_script = args.script or config.build.source_script or 'source_script'
cmd.append(source_script)
return cmd
def sign_executable(config, args):
"""签名可执行文件"""
if not args.sign:
print("跳过签名步骤")
return True
sign_tool = config.signing.sign_tool
if not sign_tool or not Path(sign_tool).exists():
print("警告: signtool.exe 未找到,跳过签名")
return False
cert_file = args.cert or config.signing.cert_file
if not cert_file or not Path(cert_file).exists():
print("警告: 证书文件不存在,跳过签名")
return False
cert_password = args.password or config.signing.cert_password or ''
timestamp_server = args.timestamp or config.signing.timestamp_server or 'http://timestamp.digicert.com'
output_dir = args.output or config.build.output_dirt
source_script = args.script or config.build.source_script
exe_name = Path(source_script).stem + ".exe"
exe_path = Path(output_dir) / exe_name
sign_cmd = [
sign_tool,
"sign",
"/tr", timestamp_server,
"/td", "sha256",
"/fd", "sha256",
"/f", cert_file,
"/p", cert_password,
str(exe_path),
]
try:
subprocess.run(sign_cmd, check=True)
print(f"✓ 签名成功: {exe_path}")
# 验证签名
verify_cmd = [sign_tool, "verify", "/pa", str(exe_path)]
subprocess.run(verify_cmd, check=True)
return True
except subprocess.CalledProcessError as e:
print(f"✗ 签名失败: {e}")
return False
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="Python打包签名工具")
parser.add_argument('--config', default='build_config.ini',
help="配置文件路径 (默认: build_config.ini)")
parser.add_argument('--script', help="覆盖配置中的源脚本")
parser.add_argument('--output', help="覆盖输出目录")
parser.add_argument('--icon', help="本地或在线ICO图标路径 (http/https)")
parser.add_argument('--jobs', help="并行进程数")
parser.add_argument('--compiler', default="mingw64", help="编译器类型")
parser.add_argument('--show-progress', default="store_true", help="显示编译进度")
parser.add_argument('--standalone', action='store_true', help="强制启用standalone模式")
parser.add_argument('--onefile', action='store_true', help="强制启用单文件模式")
parser.add_argument('--disable-console', action='store_true',
help="禁用控制台窗口 (GUI程序)")
parser.add_argument('--no-sign', dest='sign', action='store_false',
help="跳过签名步骤")
parser.add_argument('--cert', help="覆盖证书路径")
parser.add_argument('--password', help="证书密码")
parser.add_argument('--timestamp', help="覆盖时间戳服务器URL")
return parser.parse_args()
def format_file_size(size_bytes):
"""将文件大小转换为易读格式"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} TB"
def get_exe_path(config, args):
"""获取生成的exe文件路径"""
output_dir = args.output or config.build.output_dirt or 'dist'
source_script = args.script or config.build.source_script
exe_name = Path(source_script).stem + (".exe" if os.name == 'nt' else "")
return Path(output_dir) / exe_name
def main():
args = parse_args()
print("=== 开始构建 ===")
start_time = time.time()
build_success = True
sign_success = False
try:
config = load_config(args.config)
except FileNotFoundError as e:
print(f"错误: {e}")
return
# import json; print(json.dumps(config, indent=4, ensure_ascii=False))
# import json; print(json.dumps(get_settings(), indent=4, ensure_ascii=False))
# return
# 清理旧构建
output_dir = args.output or config.build.output_dir or 'dist'
if config.build.clean and Path(output_dir).exists():
try:
shutil.rmtree(output_dir)
Path(output_dir).mkdir(parents=True, exist_ok=True)
print(f"已清理输出目录: {output_dir}")
except Exception as e:
print(f"清理目录时出错: {e}")
build_success = False
product_name = (config.version.alias or config.version.name)
product_version = config.version.version
app_name = f"{product_name}{'v'+product_version if product_version else ''}"
print(f"应用名称: {app_name}")
build_success = True
if build_success:
# 编译
print("\n[1/3] 正在使用Nuitka编译...")
try:
nuitka_cmd = build_nuitka_command(config, args, app_name)
print("执行命令:", " ".join(nuitka_cmd))
compile_start = time.time()
subprocess.run(nuitka_cmd, check=True)
print(f"编译完成,耗时: {time.time() - compile_start:.2f}")
except subprocess.CalledProcessError as e:
print(f"编译失败: {e}")
build_success = False
if build_success and args.sign:
# 签名
print("\n[2/3] 正在处理签名...")
try:
sign_start = time.time()
sign_success = sign_executable(config, args)
if sign_success:
print(f"签名完成,耗时: {time.time() - sign_start:.2f}")
except Exception as e:
print(f"签名过程中出错: {e}")
sign_success = False
if build_success:
# 压缩文件
print("\n[3/3] 正在压缩文件...")
import zipfile
from datetime import datetime
zip_path = f"{output_dir}/{app_name}.zip"
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in [f'{output_dir}/{app_name}.exe', *(config.build.resource_files or [])]:
if not file: continue
filename = Path(file).name
zipf.write(file, arcname=filename)
print(f"压缩完成: {zip_path}")
except Exception as e:
print(f"压缩文件时出错: {e}")
build_success = False
zip_app_name = f"{config.version.name}{'v'+product_version if product_version else ''}.zip"
version_data = config.version.to_dict()
save_url = config.build.save_url.strip('/')
access_url = config.version.access_url.strip('/')
version_data['url'] = access_url + '/' + zip_app_name
import json
with open(f'{output_dir}/version.json', 'w', encoding='utf-8') as f:
json.dump(dict(
data=version_data,
code=200,
success='true',
message=f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - {app_name} 打包完成',
), f, ensure_ascii=False, indent=4)
print(f"版本信息已保存到: {output_dir}/version.json")
if not os.system(f'curl -T {zip_path} {save_url}/{zip_app_name}'):
print(f"上传完成: {version_data['url']}")
os.system(f'curl -T {output_dir}/version.json {save_url}/latest')
# 构建总结
print("\n=== 构建总结 ===")
total_time = time.time() - start_time
status = "成功" if build_success else "失败"
summary = [
f"构建状态: {status}",
f"总耗时: {total_time:.2f}",
f"输出目录: {output_dir}",
]
if build_success:
exe_path = get_exe_path(config, args)
if exe_path.exists():
file_time = time.strftime('%Y-%m-%d %H:%M:%S',
time.localtime(exe_path.stat().st_mtime))
summary.extend([
f"生成文件: {exe_path}",
f"文件大小: {format_file_size(exe_path.stat().st_size)}",
f"修改时间: {file_time}",
])
if args.sign:
summary.append(f"签名状态: {'成功' if sign_success else '失败'}")
print("\n".join(summary))
if not build_success:
sys.exit(1)
if __name__ == "__main__":
main()