367 lines
13 KiB
Python
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() |