背景
Sakura主题因为使用了vite构建,所以会产生一个黑洞,打包后的文件很大,需要二次删除一些不必要的文件,而且有些压缩会造成主题安装失败的问题。
考虑过在package.json
中添加脚本来打包,但不太想破坏原有结构,而且还想方便其他主题,所以python脚本。
代码
core
import os
import zipfile
import logging
import fnmatch
from pathlib import Path
from datetime import datetime
def setup_base_logger(name):
"""
设置并返回一个基础日志记录器。
参数:
name (str): 日志记录器的名称。
返回值:
logging.Logger: 配置好的日志记录器对象。
"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
return logger
def load_ignore_rules(ignore_file, logger):
"""
从指定的忽略文件中加载排除规则。
参数:
ignore_file (str): 包含排除规则的文件路径。
logger (logging.Logger): 用于记录日志的日志记录器。
返回值:
list: 加载的排除规则列表,如果文件不存在或读取失败则返回空列表。
"""
if not os.path.exists(ignore_file):
logger.debug(f"未找到配置文件: {ignore_file}")
return []
try:
with open(ignore_file, 'r', encoding='utf-8') as f:
rules = [
line.strip().replace(os.sep, '/')
for line in f
if line.strip() and not line.startswith('#')
]
logger.info(f"已加载 {len(rules)} 条排除规则 from {ignore_file}")
return rules
except Exception as e:
error_msg = f"读取配置文件失败: 文件路径 {ignore_file}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
return []
def should_exclude(rel_path: str, exclude_list: list, logger: logging.Logger) -> bool:
"""
判断给定的相对路径是否应该被排除。
参数:
rel_path (str): 需要检查的相对路径。
exclude_list (list): 排除规则列表。
logger (logging.Logger): 用于记录日志的日志记录器。
返回值:
bool: 如果路径匹配任何排除规则则返回True,否则返回False。
"""
rel_path = rel_path.replace(os.sep, '/')
def matches_pattern(pattern: str, path: str) -> bool:
pattern = pattern.rstrip('/')
if pattern.endswith('/'):
return path.startswith(pattern) or f'/{pattern}' in path
if '*' in pattern:
return fnmatch.fnmatch(path, pattern)
return path == pattern or path.startswith(pattern + '/')
for pattern in exclude_list:
if matches_pattern(pattern, rel_path):
logger.debug(f"排除匹配: [模式] {pattern} ← [路径] {rel_path}")
return True
return False
def filter_dirs(dirs, rel_root, all_excludes, logger):
original_count = len(dirs)
dirs[:] = [d for d in dirs if not should_exclude(f"{rel_root}/{d}" if rel_root != '.' else d, all_excludes, logger)]
if len(dirs) != original_count:
logger.debug(f"目录过滤: {rel_root} ({original_count}→{len(dirs)})")
def filter_files(files, root, source_dir, all_excludes, logger):
filtered_files = []
for file in files:
file_path = Path(root) / file
rel_path = file_path.relative_to(source_dir).as_posix()
if not should_exclude(rel_path, all_excludes, logger):
filtered_files.append(file)
return filtered_files
def add_file_to_zip(zipf, file_path, rel_path, logger, included_files):
try:
zipf.write(file_path, rel_path)
if included_files % 100 == 0:
logger.info(f"已处理 {included_files} 个文件")
except zipfile.BadZipFile as e:
error_msg = f"ZIP文件格式错误: 文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
except PermissionError as e:
error_msg = f"权限不足: 无法添加文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
except Exception as e:
error_msg = f"添加文件失败: 文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
def create_zip(source_dir, output_zip, exclude_list=None, ignore_file='.zipignore', logger=None):
"""
将指定目录压缩为ZIP文件,并根据排除规则过滤文件和目录。
参数:
source_dir (str): 需要压缩的源目录路径。
output_zip (str): 输出的ZIP文件路径。如果未指定文件名,则使用source_dir的文件名。
exclude_list (list, optional): 手动指定的排除规则列表,默认为None。
ignore_file (str, optional): 包含排除规则的文件路径,默认为'.zipignore'。
logger (logging.Logger, optional): 用于记录日志的日志记录器,默认为None。
返回值:
无返回值,但会在压缩过程中记录日志,并在发生错误时抛出异常。
"""
if exclude_list is None:
exclude_list = []
if logger is None:
logger = setup_base_logger('ZipCore')
start_time = datetime.now()
source_dir = Path(source_dir).resolve()
# 处理 output_zip 路径
output_zip_path = Path(output_zip)
if output_zip_path.is_dir():
# 如果 output_zip 是一个目录,使用 source_dir 的文件名生成 ZIP 文件名
source_name = Path(source_dir).name
output_zip = output_zip_path / f"{source_name}.zip"
else:
# 如果 output_zip 不是目录,直接使用其值
output_zip = output_zip_path
# 确保 output_zip 的父目录存在
output_zip.parent.mkdir(parents=True, exist_ok=True)
logger.info("=" * 50)
logger.info(f"开始压缩任务: {source_dir} → {output_zip}")
logger.info(f"启动时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"排除参数: {exclude_list}")
ignore_file_path = Path(ignore_file)
if not ignore_file_path.is_absolute() and not ignore_file_path.exists():
possible_path = source_dir / ignore_file
if possible_path.exists():
ignore_file_path = possible_path
config_ignore = load_ignore_rules(ignore_file_path, logger)
all_excludes = list(set(exclude_list + config_ignore))
logger.info(f"总排除规则数: {len(all_excludes)}")
total_files = 0
included_files = 0
try:
with zipfile.ZipFile(str(output_zip), 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipf:
for root, dirs, files in os.walk(source_dir):
rel_root = Path(root).relative_to(source_dir).as_posix()
filter_dirs(dirs, rel_root, all_excludes, logger)
filtered_files = filter_files(files, root, source_dir, all_excludes, logger)
for file in filtered_files:
total_files += 1
file_path = Path(root) / file
rel_path = file_path.relative_to(source_dir).as_posix()
included_files += 1
add_file_to_zip(zipf, file_path, rel_path, logger, included_files)
end_time = datetime.now()
duration = end_time - start_time
logger.info("压缩任务完成")
logger.info(f"扫描文件总数: {total_files}")
logger.info(f"实际包含文件: {included_files}")
logger.info(f"排除文件数量: {total_files - included_files}")
logger.info(f"耗时: {duration.total_seconds():.2f} 秒")
logger.info(f"输出文件大小: {Path(output_zip).stat().st_size / 1024 / 1024:.2f} MB")
except zipfile.LargeZipFile as e:
error_msg = f"ZIP文件过大错误: 输出文件 {output_zip}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
raise
except PermissionError as e:
error_msg = f"权限不足: 无法创建/写入ZIP文件 {output_zip}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
logger.error(error_msg)
raise
except Exception as e:
error_msg = f"压缩过程发生严重错误: 类型 {type(e).__name__}, 错误详情: {str(e)}"
logger.error(error_msg)
raise
gui
import logging
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, scrolledtext
from tkinter import ttk
from core import setup_base_logger, create_zip
def setup_logger(text_widget):
"""
设置GUI应用程序的日志记录器,将日志输出到指定的文本控件中。
参数:
text_widget (tk.Text): 用于显示日志的文本控件。
返回值:
logging.Logger: 配置好的日志记录器。
"""
logger = setup_base_logger('ZipToolGUI')
logger.setLevel(logging.DEBUG)
class TextHandler(logging.Handler):
def emit(self, record):
"""将日志消息格式化并插入到文本控件中。"""
msg = self.format(record)
text_widget.configure(state='normal')
text_widget.insert(tk.END, msg + '\n')
text_widget.see(tk.END)
text_widget.configure(state='disabled')
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', "%Y-%m-%d %H:%M:%S")
handler = TextHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def launch_gui():
"""
启动GUI应用程序,提供文件压缩功能。
该函数创建了一个包含源目录、输出文件、忽略文件选择和日志显示的用户界面。
"""
def start_zip():
"""启动压缩过程,根据用户输入调用压缩函数。"""
src = src_entry.get()
dst = dst_entry.get()
ign = ign_entry.get()
if not src:
messagebox.showwarning("输入错误", "请输入源目录")
return
if not dst:
src_path = Path(src).resolve()
dst_path = src_path.parent / f"{src_path.name}.zip"
dst_entry.insert(0, str(dst_path))
dst = str(dst_path)
threading.Thread(
target=create_zip,
kwargs={
'source_dir': src,
'output_zip': dst,
'ignore_file': ign,
'logger': logger
},
daemon=True
).start()
def browse_src():
"""打开文件对话框,选择源目录并自动填充输出文件路径。"""
path = filedialog.askdirectory()
if path:
src_entry.delete(0, tk.END)
src_entry.insert(0, path)
dst_path = Path(path).resolve().parent / f"{Path(path).resolve().name}.zip"
dst_entry.delete(0, tk.END)
dst_entry.insert(0, str(dst_path))
def browse_dst():
"""打开文件对话框,选择输出文件路径。"""
path = filedialog.asksaveasfilename(defaultextension=".zip",
filetypes=[("ZIP 文件", "*.zip")])
if path:
dst_entry.delete(0, tk.END)
dst_entry.insert(0, path)
def browse_ign():
"""打开文件对话框,选择忽略规则文件。"""
path = filedialog.askopenfilename(filetypes=[("忽略文件", "*.zipignore"), ("所有文件", "*.*")])
if path:
ign_entry.delete(0, tk.END)
ign_entry.insert(0, path)
# 创建主窗口并设置基本属性
root = tk.Tk()
root.title("ZIP 压缩工具 - GUI 版")
root.geometry("700x550")
root.configure(bg="#f0f0f0")
# 设置ttk主题样式
style = ttk.Style()
style.theme_use('clam')
style.configure("TButton", padding=6, font=("Helvetica", 10))
style.configure("TLabel", font=("Helvetica", 10), background="#f0f0f0")
style.configure("TEntry", padding=5)
# 创建主框架并布局
main_frame = ttk.Frame(root, padding="15")
main_frame.grid(row=0, column=0, sticky='nsew')
root.grid_rowconfigure(0, weight=1)
root.grid_columnconfigure(0, weight=1)
# 源目录选择控件
ttk.Label(main_frame, text="压缩文件:").grid(row=0, column=0, sticky='e', pady=5)
src_entry = ttk.Entry(main_frame, width=50)
src_entry.grid(row=0, column=1, pady=5)
src_button = ttk.Button(main_frame, text="浏览", command=browse_src)
src_button.grid(row=0, column=2, padx=5, pady=5)
# 鼠标悬停提示
src_button.bind('<Enter>', lambda e: src_button.config(text="选择要压缩的文件"))
src_button.bind('<Leave>', lambda e: src_button.config(text="浏览"))
# 输出文件选择控件
ttk.Label(main_frame, text="输出文件:").grid(row=1, column=0, sticky='e', pady=5)
dst_entry = ttk.Entry(main_frame, width=50)
dst_entry.grid(row=1, column=1, pady=5)
dst_button = ttk.Button(main_frame, text="浏览", command=browse_dst)
dst_button.grid(row=1, column=2, padx=5, pady=5)
# 鼠标悬停提示
dst_button.bind('<Enter>', lambda e: dst_button.config(text="选择保存ZIP文件的位置"))
dst_button.bind('<Leave>', lambda e: dst_button.config(text="浏览"))
# 忽略文件选择控件
ttk.Label(main_frame, text="忽略文件:").grid(row=2, column=0, sticky='e', pady=5)
ign_entry = ttk.Entry(main_frame, width=50)
ign_entry.grid(row=2, column=1, pady=5)
ign_button = ttk.Button(main_frame, text="浏览", command=browse_ign)
ign_button.grid(row=2, column=2, padx=5, pady=5)
# 鼠标悬停提示
ign_button.bind('<Enter>', lambda e: ign_button.config(text="选择忽略规则文件"))
ign_button.bind('<Leave>', lambda e: ign_button.config(text="浏览"))
# 开始压缩按钮
start_button = ttk.Button(main_frame, text="开始压缩", command=start_zip, style="Accent.TButton")
style.configure("Accent.TButton", font=("Helvetica", 11, "bold"), background="#4CAF50", foreground="white")
start_button.grid(row=3, column=1, pady=20)
# 日志显示区域
log_frame = ttk.Frame(main_frame)
log_frame.grid(row=4, column=0, columnspan=3, sticky='nsew')
log_text = scrolledtext.ScrolledText(log_frame, height=15, state='disabled', font=("Courier", 9), wrap=tk.WORD)
log_text.pack(fill='both', expand=True, padx=5, pady=5)
main_frame.grid_rowconfigure(4, weight=1)
main_frame.grid_columnconfigure(1, weight=1)
global logger
logger = setup_logger(log_text)
root.mainloop()
if __name__ == '__main__':
launch_gui()
cli
import argparse
import logging
from pathlib import Path
from core import setup_base_logger, create_zip
def setup_logger(output_zip):
"""
为CLI应用程序设置扩展的日志记录器。
该函数配置了一个日志记录器,用于记录应用程序的运行日志。日志会同时输出到控制台和指定的日志文件中。
参数:
output_zip (str): 输出的ZIP文件路径,日志文件将与ZIP文件同名,但扩展名为.log。
返回值:
logging.Logger: 配置好的日志记录器对象。
"""
logger = setup_base_logger('ZipTool')
log_path = Path(output_zip).with_suffix('.log')
file_handler = logging.FileHandler(log_path, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
def main():
"""
主函数,用于解析命令行参数并执行ZIP压缩操作。
该函数解析命令行参数,配置日志记录器,并调用核心函数进行ZIP压缩。如果发生异常,程序将记录错误并退出。
"""
parser = argparse.ArgumentParser(description='带日志记录的ZIP压缩工具')
parser.add_argument('source_dir', help='要压缩的源目录')
parser.add_argument('output_zip', nargs='?', default=None,
help='输出的ZIP文件路径(默认为源目录同名ZIP文件,位于同级目录)')
parser.add_argument('--exclude', nargs='+', default=[],
help='命令行排除规则(可选)')
parser.add_argument('--ignore-file', default='.zipignore',
help='指定配置文件(默认为 .zipignore)')
parser.add_argument('--verbose', action='store_true',
help='显示详细日志')
args = parser.parse_args()
try:
# 如果未指定输出ZIP文件路径,则默认使用源目录的同名ZIP文件
output_zip = args.output_zip
if output_zip is None:
source_dir = Path(args.source_dir).resolve()
output_zip = source_dir.parent / f"{source_dir.name}.zip"
# 调用核心函数进行ZIP压缩,并传递日志记录器
create_zip(
source_dir=args.source_dir,
output_zip=output_zip,
exclude_list=args.exclude,
ignore_file=args.ignore_file,
logger=setup_logger(output_zip)
)
except Exception as e:
# 捕获并记录异常,程序异常终止
logging.error(f"程序异常终止: {str(e)}")
exit(1)
if __name__ == '__main__':
main()
zipignore示例
.git
.gitignore
.github
.idea
.vscode
.husky
.all-contributorsrc
.editorconfig
.env
.gitignore
.zipignore
docs
node_modules
src
package.json
pnpm-lock.yaml
postcss.config.js
prettier.config.js
README.md
screenshot.png
tsconfig.json
vite.config.ts
vite-env.d.ts
使用
cli
python cli.py .\halo-theme-sakura\ --ignore-file .\halo-theme-sakura\.zipignore
python cli.py .\halo-theme-sakura\ ..\ --ignore-file .\halo-theme-sakura\.zipignore
python cli.py .\halo-theme-sakura\ .\halo-theme-sakura.2.4.3.3.zip --exclude .git .github .idea ...