#!/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 = "AC7016C(BR28)" # 复制时跳过的目录名(任意层级) 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())