Files
2026-08-29 11:06:52 +08:00

638 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
###############################################################################
# @file package_release.py
# @brief 根据当前开发工程自动生成客户交付 Release 工程
# @author cyWu <1917507415@qq.com>
# @date 2026.08.04
# @version V1.0.0
# @history
# - V1.0.0, 2026.08.04, cyWu, 首次发布
# - V1.0.1, 2026.08.04, cyWu, 产物统一输出到 release/ 目录
# - V1.0.2, 2026.08.05, cyWu, 交付包命名改为 JBao_JL7016_SOC_SDK_Vx.x.x
#
# 使用方式(在项目根或任意目录):
# python tools/package_release.py
#
# 约束:
# - 仅使用 Python 标准库
# - 所有删除 / 过滤 / 复制只作用于 Release 副本
# - 绝不修改开发工程业务源码、Makefile、build_lib.bat、CodeBlocks 工程
###############################################################################
from __future__ import annotations
import fnmatch
import os
import re
import shutil
import sys
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Set
# =============================================================================
# 可配置区域(集中管理,扩展模块只改这里)
# =============================================================================
# 版本文件(相对项目根)
VERSION_FILE = "version.txt"
# Release 输出根目录(相对项目根,产物全部放这里)
RELEASE_OUTPUT_DIR = "release"
# Release 命名规则
RELEASE_DIR_PREFIX = "JBao_JL7016_SOC_SDK_V"
RELEASE_ZIP_SUFFIX = ".zip"
README_NAME = "JBao_JL7016_SOC_SDK_README.md"
# 工程展示名称(写入 README
PROJECT_DISPLAY_NAME = "AC701N Earphone SDK"
CHIP_DISPLAY_NAME = "AC7016CBR28"
# 复制时跳过的目录名(任意层级)
SKIP_DIR_NAMES: Set[str] = {
".git",
".vscode",
".idea",
"objs",
"obj",
"__pycache__",
RELEASE_OUTPUT_DIR, # 不把已有交付产物拷进新包
}
# 复制时跳过的根目录文件(仅项目根下,不进客户包)
SKIP_ROOT_FILES: Set[str] = {
".gitignore",
"version.txt",
}
# 复制时跳过的文件通配(任意层级,按文件名匹配)
SKIP_FILE_PATTERNS: List[str] = [
"*.o",
"*.d",
"*.dep",
"*.obj",
"*.bak",
"*.log",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.orig",
"*.rej",
"*.pyc",
"*.layout",
"*.depend",
"Thumbs.db",
".DS_Store",
]
# 复制时跳过的路径前缀(相对项目根,防自复制嵌套)
SKIP_PATH_PREFIXES: List[str] = [
RELEASE_OUTPUT_DIR,
"Release_Project_V", # 兼容旧版曾放在项目根的产物
"JBao_JL7016_SOC_SDK_V", # 兼容曾放在项目根的新命名产物
]
# ---------------------------------------------------------------------------
# 需要封装交付的模块配置
# ---------------------------------------------------------------------------
# public_headers : 保留的公开头文件(仅文件名,相对模块根或子目录中同名匹配)
# remove_patterns : 删除的源码通配(递归)
# remove_internal_headers : True 时删除非 public_headers 的所有 .h/.hpp
# prune_empty_dirs : 清理后删除空目录
# remove_extra : 额外删除的相对路径(相对模块根)
# keep_extra : 额外强制保留的相对路径(文件或目录,相对模块根)
# ---------------------------------------------------------------------------
MODULE_CONFIG: Dict[str, dict] = {
"apps/usr_le_code": {
"public_headers": [
"usr_le_api.h",
"usr_le_product.h",
],
"remove_patterns": [
"*.c",
"*.cpp",
"*.cc",
],
"remove_internal_headers": True,
"prune_empty_dirs": True,
"remove_extra": [
"build_lib.bat",
],
"keep_extra": [
"lib/",
],
},
}
# =============================================================================
# 路径管理
# =============================================================================
def get_project_root() -> Path:
"""
@brief 由脚本位置推导项目根目录(tools/ 的上一级)
@return 项目根 Path
"""
return Path(__file__).resolve().parent.parent
def read_version(project_root: Path) -> str:
"""
@brief 读取 version.txt
@param project_root 项目根
@return 版本号字符串,例如 "1.0.0"
"""
version_path = project_root / VERSION_FILE
if not version_path.is_file():
raise FileNotFoundError(f"version file not found: {version_path}")
version = version_path.read_text(encoding="utf-8").strip()
if not version:
raise ValueError(f"version file is empty: {version_path}")
# 简单校验:不允许路径分隔符,避免生成非法目录名
if any(ch in version for ch in ("/", "\\", "..")):
raise ValueError(f"invalid version string: {version!r}")
return version
def get_release_paths(project_root: Path, version: str) -> dict:
"""
@brief 统一生成 Release 相关路径(均位于 release/ 下)
@param project_root 项目根
@param version 版本号
@return 含 output_dir / dir / zip / readme 的字典
"""
dir_name = f"{RELEASE_DIR_PREFIX}{version}"
output_dir = project_root / RELEASE_OUTPUT_DIR
release_dir = output_dir / dir_name
release_zip = output_dir / f"{dir_name}{RELEASE_ZIP_SUFFIX}"
readme_path = release_dir / README_NAME
return {
"dir_name": dir_name,
"output_dir": output_dir,
"release_dir": release_dir,
"release_zip": release_zip,
"readme_path": readme_path,
}
# =============================================================================
# 复制过滤
# =============================================================================
def _match_any(name: str, patterns: Iterable[str]) -> bool:
"""
@brief 判断文件名是否匹配任一通配符
"""
return any(fnmatch.fnmatch(name, pat) for pat in patterns)
def should_skip_path(rel_path: Path) -> bool:
"""
@brief 判断相对项目根的路径在复制时是否应跳过
@param rel_path 相对路径
@return True=跳过
"""
parts = rel_path.parts
if not parts:
return False
# 跳过项目根下指定文件(如 .gitignore / version.txt
if len(parts) == 1 and parts[0] in SKIP_ROOT_FILES:
return True
# 跳过指定目录名(任意层级)
for part in parts:
if part in SKIP_DIR_NAMES:
return True
# 跳过 Release 产物路径(防嵌套)
rel_posix = rel_path.as_posix()
for prefix in SKIP_PATH_PREFIXES:
if rel_posix == prefix.rstrip("/") or rel_posix.startswith(prefix):
return True
# 跳过匹配的文件名
if _match_any(rel_path.name, SKIP_FILE_PATTERNS):
return True
# 跳过已有 zip 包(根目录下)
if len(parts) == 1 and rel_path.name.endswith(RELEASE_ZIP_SUFFIX):
if rel_path.name.startswith(RELEASE_DIR_PREFIX):
return True
return False
def copy_project(project_root: Path, release_dir: Path) -> int:
"""
@brief 过滤复制整工程到 Release 目录
@param project_root 开发工程根
@param release_dir 目标 Release 目录
@return 复制的文件数量
"""
copied = 0
for root, dirs, files in os.walk(project_root):
root_path = Path(root)
rel_root = root_path.relative_to(project_root)
# 就地过滤子目录,避免继续向下遍历
keep_dirs: List[str] = []
for d in dirs:
candidate = rel_root / d if str(rel_root) != "." else Path(d)
if should_skip_path(candidate):
continue
keep_dirs.append(d)
dirs[:] = keep_dirs
# 目标目录
dst_root = release_dir if str(rel_root) == "." else release_dir / rel_root
dst_root.mkdir(parents=True, exist_ok=True)
for name in files:
rel_file = rel_root / name if str(rel_root) != "." else Path(name)
if should_skip_path(rel_file):
continue
src = root_path / name
dst = dst_root / name
shutil.copy2(src, dst)
copied += 1
return copied
# =============================================================================
# 模块清理(仅作用于 Release 副本)
# =============================================================================
def _is_under_keep_extra(rel_posix: str, keep_extra: List[str]) -> bool:
"""
@brief 判断模块内相对路径是否属于 keep_extra 保护范围
"""
for keep in keep_extra:
keep_norm = keep.replace("\\", "/").rstrip("/")
if not keep_norm:
continue
# 目录保护:keep 以 / 结尾或配置为目录前缀
if rel_posix == keep_norm or rel_posix.startswith(keep_norm + "/"):
return True
# 兼容配置写成 "lib/" 的情况已在上方处理
if keep.endswith("/") and (rel_posix == keep_norm or rel_posix.startswith(keep_norm + "/")):
return True
return False
def _prune_empty_dirs(module_dir: Path) -> int:
"""
@brief 自底向上删除空目录
@return 删除的空目录数量
"""
removed = 0
# 深度优先:按路径长度降序
all_dirs = sorted(
(p for p in module_dir.rglob("*") if p.is_dir()),
key=lambda p: len(p.parts),
reverse=True,
)
for d in all_dirs:
try:
if not any(d.iterdir()):
d.rmdir()
removed += 1
except OSError:
pass
return removed
def clean_module(release_dir: Path, module_rel: str, cfg: dict) -> dict:
"""
@brief 按配置清理单个封装模块(仅 Release 内)
@param release_dir Release 根目录
@param module_rel 模块相对路径,如 apps/usr_le_code
@param cfg MODULE_CONFIG 中的单项配置
@return 统计信息字典
"""
module_dir = release_dir / module_rel
stats = {
"module": module_rel,
"removed_sources": 0,
"removed_headers": 0,
"removed_extra": 0,
"pruned_dirs": 0,
"missing": False,
}
if not module_dir.is_dir():
stats["missing"] = True
print(f"[WARN] module not found in Release: {module_rel}")
return stats
public_headers: Set[str] = set(cfg.get("public_headers", []))
remove_patterns: List[str] = list(cfg.get("remove_patterns", []))
remove_internal_headers: bool = bool(cfg.get("remove_internal_headers", True))
prune_empty_dirs: bool = bool(cfg.get("prune_empty_dirs", True))
remove_extra: List[str] = list(cfg.get("remove_extra", []))
keep_extra: List[str] = list(cfg.get("keep_extra", []))
# 1) 删除匹配 remove_patterns 的源文件
for path in list(module_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(module_dir).as_posix()
if _is_under_keep_extra(rel, keep_extra):
continue
if _match_any(path.name, remove_patterns):
path.unlink()
stats["removed_sources"] += 1
# 2) 删除非公开头文件
if remove_internal_headers:
header_patterns = ["*.h", "*.hpp"]
for path in list(module_dir.rglob("*")):
if not path.is_file():
continue
if not _match_any(path.name, header_patterns):
continue
rel = path.relative_to(module_dir).as_posix()
if _is_under_keep_extra(rel, keep_extra):
continue
# 公开头文件按“文件名”匹配,便于配置只写文件名
if path.name in public_headers:
continue
path.unlink()
stats["removed_headers"] += 1
# 3) 删除额外指定文件/目录
for extra in remove_extra:
target = module_dir / extra
if not target.exists():
continue
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
stats["removed_extra"] += 1
# 4) 清理空目录
if prune_empty_dirs:
stats["pruned_dirs"] = _prune_empty_dirs(module_dir)
return stats
def clean_all_modules(release_dir: Path) -> List[dict]:
"""
@brief 遍历 MODULE_CONFIG 清理所有模块
"""
results = []
for module_rel, cfg in MODULE_CONFIG.items():
print(f"[INFO] cleaning module: {module_rel}")
results.append(clean_module(release_dir, module_rel, cfg))
return results
def sanitize_release_makefile(release_dir: Path) -> None:
"""
@brief 调整 Release 副本中的 Makefile:仅链接预编译 libusr_le_code.a
开发工程 Makefile 不动;客户包不得再尝试用已删除源码重建库
@param release_dir Release 根目录
"""
makefile = release_dir / "Makefile"
if not makefile.is_file():
print("[WARN] Makefile not found in Release, skip sanitize")
return
text = makefile.read_text(encoding="utf-8")
# 1) 去掉源码列表与 OBJS,仅保留预编译库路径
text2, n1 = re.subn(
r"# usr_le_code 静态库[^\n]*\n"
r"USR_LE_LIB := apps/usr_le_code/lib/libusr_le_code\.a\n"
r"USR_LE_SRC_FILES := \\\n"
r"(?:[ \t]+apps/usr_le_code/[^\n]+\n)+"
r"\n"
r"USR_LE_OBJS :=[^\n]+\n",
"# usr_le_code 预编译静态库(Release 不附带协议源码,禁止本地重建)\n"
"USR_LE_LIB := apps/usr_le_code/lib/libusr_le_code.a\n"
"\n",
text,
count=1,
)
if n1 == 0:
raise RuntimeError("sanitize Makefile failed: USR_LE_SRC_FILES block not found")
# 2) .PHONY 去掉 lib_usr_le
text2, n2 = re.subn(
r"\.PHONY:\s*all clean pre_build lib_usr_le\b",
".PHONY: all clean pre_build",
text2,
count=1,
)
if n2 == 0:
# 兼容顺序变化,尽量剥离 lib_usr_le
text2 = re.sub(r"\s+lib_usr_le\b", "", text2, count=1)
# 3) 删除 lib_usr_le 目标与 $(USR_LE_LIB): $(USR_LE_OBJS) 重建规则
# 保留 all / OUT_ELF 对 $(USR_LE_LIB) 的依赖:库文件已存在即可链接
text2, n3 = re.subn(
r"\n# 单独编译 usr_le_code 静态库:[^\n]*\n"
r"lib_usr_le:[^\n]*\n"
r"\n"
r"\$\(USR_LE_LIB\): \$\(USR_LE_OBJS\)\n"
r"(?:[ \t]+[^\n]+\n)+",
"\n",
text2,
count=1,
)
if n3 == 0:
raise RuntimeError("sanitize Makefile failed: lib_usr_le rebuild rule not found")
makefile.write_text(text2, encoding="utf-8")
print("[INFO] sanitized Release Makefile (prebuilt libusr_le_code.a only)")
# =============================================================================
# README / ZIP
# =============================================================================
def write_readme(readme_path: Path, version: str, module_stats: List[dict]) -> None:
"""
@brief 生成 JBao_JL7016_SOC_SDK_README.md
@param readme_path README 路径
@param version 版本号
@param module_stats 模块清理统计(用于目录说明)
"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines: List[str] = [
f"# {PROJECT_DISPLAY_NAME}",
"",
"## 基本信息",
"",
f"- 工程名称:{PROJECT_DISPLAY_NAME}",
f"- 版本号:V{version}",
f"- 生成时间:{now}",
f"- 适用芯片:{CHIP_DISPLAY_NAME}",
"",
"## 目录说明",
"",
"```",
f"{RELEASE_DIR_PREFIX}{version}/",
"├── apps/",
"│ ├── common/ # SDK 公共模块",
"│ ├── earphone/ # 耳机应用",
"│ ├── usr_jb_proto/ # 应用协议层",
"│ ├── usr_periph/ # 板级外设(RTC 等,源码开放)",
"│ └── usr_le_code/ # BLE 协议库(库文件 + 公开头文件)",
"├── cpu/ # 芯片相关代码与工具",
"├── include_lib/ # SDK 头文件",
"├── tools/ # 工程工具",
"├── Makefile",
"├── AC701N.cbp",
f"└── {README_NAME}",
"```",
"",
"### 封装模块公开接口",
"",
]
for module_rel, cfg in MODULE_CONFIG.items():
public_headers = cfg.get("public_headers", [])
lines.append(f"**{module_rel}**")
lines.append("")
lines.append(f"- 静态库:`{module_rel}/lib/`")
lines.append("- 公开头文件:")
for h in public_headers:
lines.append(f" - `{module_rel}/{h}`")
lines.append("")
lines.append("---")
lines.append("")
lines.append("*本文件由 tools/package_release.py 自动生成。*")
lines.append("")
readme_path.write_text("\n".join(lines), encoding="utf-8")
def make_zip(release_dir: Path, release_zip: Path) -> None:
"""
@brief 将 Release 目录压缩为 zip(与目录同级)
@param release_dir Release 目录
@param release_zip 目标 zip 路径
"""
if release_zip.exists():
release_zip.unlink()
with zipfile.ZipFile(release_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in release_dir.rglob("*"):
if path.is_file():
arcname = path.relative_to(release_dir.parent)
zf.write(path, arcname.as_posix())
# =============================================================================
# 主流程
# =============================================================================
def remove_old_release(release_dir: Path, release_zip: Path) -> None:
"""
@brief 删除已有同名 Release 目录与 zip
"""
if release_dir.exists():
print(f"[INFO] remove old release dir: {release_dir.name}")
shutil.rmtree(release_dir)
if release_zip.exists():
print(f"[INFO] remove old release zip: {release_zip.name}")
release_zip.unlink()
def main() -> int:
"""
@brief Release 打包主入口
@return 进程退出码,0=成功
"""
try:
project_root = get_project_root()
version = read_version(project_root)
paths = get_release_paths(project_root, version)
release_dir: Path = paths["release_dir"]
release_zip: Path = paths["release_zip"]
readme_path: Path = paths["readme_path"]
dir_name: str = paths["dir_name"]
print("=" * 60)
print(" AC701N Release Package Tool")
print("=" * 60)
output_dir: Path = paths["output_dir"]
print(f"[INFO] project root : {project_root}")
print(f"[INFO] version : {version}")
print(f"[INFO] output dir : {RELEASE_OUTPUT_DIR}/")
print(f"[INFO] release dir : {RELEASE_OUTPUT_DIR}/{dir_name}")
print()
# 确保 release/ 输出目录存在
output_dir.mkdir(parents=True, exist_ok=True)
# ① 删除旧产物
remove_old_release(release_dir, release_zip)
# ② 过滤复制
print("[INFO] copying project ...")
copied = copy_project(project_root, release_dir)
print(f"[INFO] copied files : {copied}")
# ③ 模块清理(仅 Release 副本)
print("[INFO] cleaning encapsulated modules ...")
module_stats = clean_all_modules(release_dir)
for st in module_stats:
if st["missing"]:
continue
print(
f" - {st['module']}: "
f"src={st['removed_sources']}, "
f"hdr={st['removed_headers']}, "
f"extra={st['removed_extra']}, "
f"empty_dirs={st['pruned_dirs']}"
)
# ④ Release Makefile:禁止用已删除源码重建库(开发工程 Makefile 不动)
print("[INFO] sanitizing Release Makefile ...")
sanitize_release_makefile(release_dir)
# ⑤ README
print("[INFO] writing README ...")
write_readme(readme_path, version, module_stats)
# ⑥ ZIP
print("[INFO] creating zip ...")
make_zip(release_dir, release_zip)
print()
print("=" * 60)
print("[OK] Release package generated successfully")
print(f" dir : {release_dir}")
print(f" zip : {release_zip}")
print("=" * 60)
return 0
except Exception as exc:
print(f"[FAIL] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())