patch.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. #!/usr/bin/env python3
  2. import json
  3. import os
  4. import re
  5. import sys
  6. import time
  7. import urllib.request
  8. from datetime import datetime, timezone
  9. ROOT = "."
  10. init_py_path = os.path.join(ROOT, "ddns", "__init__.py")
  11. def extract_pure_version(version_str):
  12. """
  13. 提取前4组数字并用点拼接,如 v1.2.3.beta4.5 -> 1.2.3.4
  14. """
  15. nums = re.findall(r"\d+", version_str)
  16. return ".".join(nums[:4]) if nums else "0.0.0"
  17. def update_nuitka_version(pyfile, version=None):
  18. """
  19. 读取 __version__ 并替换 nuitka-project 版本号
  20. """
  21. pure_version = extract_pure_version(version)
  22. with open(pyfile, "r", encoding="utf-8") as f:
  23. content = f.read()
  24. # 替换 nuitka-project 行
  25. new_content, n = re.subn(r"(# nuitka-project: --product-version=)[^\n]*", r"\g<1>" + pure_version, content)
  26. if n > 0:
  27. with open(pyfile, "w", encoding="utf-8") as f:
  28. f.write(new_content)
  29. print(f"update nuitka-project version: {pure_version} in {pyfile}")
  30. return True
  31. return False
  32. def add_nuitka_file_description(pyfile):
  33. """
  34. 添加 --file-description 配置,使用 __description__ 变量的值
  35. """
  36. with open(init_py_path, "r", encoding="utf-8") as f:
  37. content = f.read()
  38. # 提取 __description__ 变量的值
  39. desc_match = re.search(r'__description__\s*=\s*[\'"]([^\'"]+)[\'"]', content)
  40. if not desc_match:
  41. print(f"No __description__ found in {init_py_path}")
  42. return False
  43. description = desc_match.group(1)
  44. description_line = f'\n# nuitka-project: --file-description="{description}"\n'
  45. with open(pyfile, "a", encoding="utf-8") as f:
  46. f.write(description_line)
  47. return True
  48. def add_nuitka_include_modules(pyfile):
  49. """
  50. 读取 dns 目录下的所有 Python 模块,并添加到 run.py 末尾
  51. """
  52. dns_dir = os.path.join(ROOT, "ddns/provider")
  53. if not os.path.exists(dns_dir):
  54. print(f"DNS directory not found: {dns_dir}")
  55. return False
  56. # 获取所有 Python 模块文件
  57. modules = []
  58. for filename in os.listdir(dns_dir):
  59. if filename.endswith(".py") and filename != "__init__.py":
  60. module_name = filename[:-3] # 去掉 .py 扩展名
  61. modules.append(f"ddns.provider.{module_name}")
  62. if not modules:
  63. print("No DNS modules found")
  64. return False
  65. # 直接在文件末尾追加配置行
  66. with open(pyfile, "a", encoding="utf-8") as f:
  67. for module in sorted(modules):
  68. f.write(f"# nuitka-project: --include-module={module}\n")
  69. print(f"Added {len(modules)} DNS modules to {pyfile}: {', '.join(modules)}")
  70. return True
  71. def add_nuitka_windows_unbuffered(pyfile):
  72. """
  73. 为Windows平台在现有的 --python-flag 配置中添加 unbuffered 标志
  74. """
  75. import platform
  76. # 只在Windows平台执行
  77. if platform.system().lower() != "windows":
  78. print(f"Skipping unbuffered flag addition: not on Windows (current: {platform.system()})")
  79. return False
  80. with open(pyfile, "r", encoding="utf-8") as f:
  81. content = f.read()
  82. # 查找现有的 --python-flag 配置行
  83. python_flag_pattern = r"(# nuitka-project: --python-flag=)([^\n]*)"
  84. match = re.search(python_flag_pattern, content)
  85. if not match:
  86. print(f"No existing --python-flag found in {pyfile}")
  87. return False
  88. existing_flags = match.group(2)
  89. # 检查是否已经包含 unbuffered
  90. if "unbuffered" in existing_flags:
  91. print(f"unbuffered flag already exists in {pyfile}")
  92. return False
  93. # 添加 unbuffered 到现有标志
  94. new_flags = "unbuffered" if not existing_flags.strip() else existing_flags + ",unbuffered"
  95. new_content = re.sub(python_flag_pattern, r"\g<1>" + new_flags, content)
  96. with open(pyfile, "w", encoding="utf-8") as f:
  97. f.write(new_content)
  98. print(f"Added unbuffered to python-flag in {pyfile}: {new_flags}")
  99. return True
  100. def remove_scheduler_for_docker():
  101. """
  102. 为Docker构建移除scheduler相关代码和task子命令
  103. 通过注释方式保持行号不变,便于调试
  104. """
  105. import shutil
  106. # 1. 移除scheduler文件夹
  107. scheduler_dir = os.path.join(ROOT, "ddns", "scheduler")
  108. if os.path.exists(scheduler_dir):
  109. shutil.rmtree(scheduler_dir)
  110. print(f"Removed scheduler directory: {scheduler_dir}")
  111. # 2. 修改ddns/config/cli.py,注释掉scheduler相关代码(保持行号不变)
  112. cli_path = os.path.join(ROOT, "ddns", "config", "cli.py")
  113. if not os.path.exists(cli_path):
  114. return False
  115. with open(cli_path, "r", encoding="utf-8") as f:
  116. content = f.read()
  117. # 注释掉scheduler导入
  118. content = re.sub(r"^(from \.\.scheduler import get_scheduler)$", r"# \1", content, flags=re.MULTILINE)
  119. # 注释掉函数调用
  120. content = re.sub(r"^(\s*)(_add_task_subcommand_if_needed\(parser\))$", r"\1# \2", content, flags=re.MULTILINE)
  121. # 注释掉整个函数块,保持行号
  122. target_functions = ["_add_task_subcommand_if_needed", "_handle_task_command", "_print_status"]
  123. for func_name in target_functions:
  124. # 匹配函数定义到下一个函数或文件结尾
  125. pattern = rf"([ \t]*def {func_name}\s*\(.*?\):(?:.*?\n)*?)(?=^[ \t]*def |\Z)"
  126. def comment_block(match):
  127. block = match.group(1)
  128. lines = block.split("\n")
  129. commented_lines = []
  130. for line in lines:
  131. if line.strip(): # 非空行
  132. # 在每行前加注释
  133. commented_lines.append("# " + line)
  134. else: # 空行保持原样
  135. commented_lines.append(line)
  136. return "\n".join(commented_lines)
  137. content = re.sub(pattern, comment_block, content, flags=re.DOTALL | re.MULTILINE)
  138. with open(cli_path, "w", encoding="utf-8") as f:
  139. f.write(content)
  140. print(f"Commented out scheduler-related code in {cli_path} (preserving line numbers)")
  141. return True
  142. def remove_python2_compatibility(pyfile): # noqa: C901
  143. """
  144. 自动将所有 try-except python2/3 兼容导入替换为 python3 only 导入,并显示处理日志
  145. 删除指定文件中的 python2 兼容代码,逐行处理
  146. """
  147. with open(pyfile, "r", encoding="utf-8") as f:
  148. lines = f.readlines()
  149. new_lines = []
  150. i = 0
  151. changed = False
  152. while i < len(lines):
  153. line = lines[i]
  154. # 匹配 "try: # python3" 或 "try: # python 3" (包括更复杂的注释)
  155. if re.match(r"^[ \t]*try:[^\n]*python ?3(?:[^\n]*python ?2)?", line):
  156. indent_match = re.match(r"^([ \t]*)", line)
  157. base_indent = indent_match.group(1) if indent_match else ""
  158. try_indent_level = len(base_indent)
  159. try_block = []
  160. i += 1
  161. # 收集try块内容:只收集缩进比try行更深的行
  162. while i < len(lines):
  163. current_line = lines[i]
  164. # 如果是空行,跳过
  165. if current_line.strip() == "":
  166. break
  167. # 检查缩进级别
  168. current_indent_match = re.match(r"^([ \t]*)", current_line)
  169. current_indent = current_indent_match.group(1) if current_indent_match else ""
  170. current_indent_level = len(current_indent)
  171. # 如果缩进不比try行深,说明try块结束了
  172. if current_indent_level <= try_indent_level:
  173. break
  174. try_block.append(current_line)
  175. i += 1
  176. # 跳过空行
  177. while i < len(lines) and lines[i].strip() == "":
  178. i += 1
  179. # 检查except块 (必须包含python2字样,并且可能包含TypeError或AttributeError)
  180. if i < len(lines) and re.match(r"^[ \t]*except[^\n]*python ?2", lines[i]):
  181. i += 1
  182. # 收集except块内容
  183. except_block = []
  184. while i < len(lines):
  185. current_line = lines[i]
  186. # 如果是空行或缩进不比except行深,except块结束
  187. if current_line.strip() == "":
  188. break
  189. current_indent_match = re.match(r"^([ \t]*)", current_line)
  190. current_indent = current_indent_match.group(1) if current_indent_match else ""
  191. current_indent_level = len(current_indent)
  192. if current_indent_level <= try_indent_level:
  193. break
  194. except_block.append(current_line)
  195. i += 1
  196. # 处理try块内容:保持原有缩进或去除缩进(根据是否在模块级别)
  197. processed_try_block = []
  198. for try_line in try_block:
  199. if base_indent == "": # 模块级别,去除所有缩进
  200. processed_try_block.append(try_line.lstrip())
  201. else: # 函数/类内部,保持基础缩进
  202. if try_line.strip():
  203. processed_try_block.append(base_indent + try_line.lstrip())
  204. else:
  205. processed_try_block.append(try_line)
  206. # 保持行号不变:try行用空行替换,except行和except块内容也用空行替换
  207. new_lines.append("\n") # try行替换为空行
  208. new_lines.extend(processed_try_block) # 保留try块内容
  209. new_lines.append("\n") # except行替换为空行
  210. new_lines.extend(["\n"] * len(except_block)) # except块内容用空行替换
  211. changed = True
  212. else:
  213. # 没有except块,原样保留
  214. new_lines.append(line)
  215. new_lines.extend(try_block)
  216. else:
  217. new_lines.append(line)
  218. i += 1
  219. if changed:
  220. with open(pyfile, "w", encoding="utf-8") as f:
  221. f.writelines(new_lines)
  222. print(f"Removed python2 compatibility from {pyfile}")
  223. def get_latest_tag():
  224. url = "https://api.github.com/repos/NewFuture/DDNS/tags?per_page=1"
  225. try:
  226. with urllib.request.urlopen(url) as response:
  227. data = json.load(response)
  228. if data and isinstance(data, list):
  229. return data[0]["name"] # 获取第一个 tag 的 name
  230. except Exception as e:
  231. print("Error fetching tag:", e)
  232. return None
  233. def normalize_tag(tag: str) -> str:
  234. v = tag.lower().lstrip("v")
  235. v = re.sub(r"-beta(\d*)", r"b\1", v)
  236. v = re.sub(r"-alpha(\d*)", r"a\1", v)
  237. v = re.sub(r"-rc(\d*)", r"rc\1", v)
  238. return v
  239. def ten_minute_bucket_id():
  240. epoch_minutes = int(time.time() // 60) # 当前时间(分钟级)
  241. bucket = epoch_minutes // 10 # 每10分钟为一个 bucket
  242. return bucket % 65536 # 限制在 0~65535 (2**16)
  243. def generate_version():
  244. ref = os.environ.get("GITHUB_REF_NAME", "")
  245. if re.match(r"^v\d+\.\d+", ref):
  246. return normalize_tag(ref)
  247. base = "4.0.0"
  248. suffix = ten_minute_bucket_id()
  249. if ref == "master" or ref == "main":
  250. tag = get_latest_tag()
  251. if tag:
  252. base = normalize_tag(tag)
  253. return f"{base}.dev{suffix}"
  254. def resolve_version(mode: str) -> str:
  255. """
  256. 仅在 PyPI 发布步骤(mode = 'version')使用 generate_version;
  257. 其他步骤优先使用标签 GITHUB_REF_NAME(规范化),没有标签时回退到 generate_version。
  258. """
  259. if mode == "version":
  260. return generate_version()
  261. ref = os.environ.get("GITHUB_REF_NAME", "")
  262. if re.match(r"^v\d+\.\d+", ref):
  263. return normalize_tag(ref)
  264. return generate_version()
  265. def replace_version_and_date(pyfile: str, version: str, date_str: str):
  266. with open(pyfile, "r", encoding="utf-8") as f:
  267. text = f.read()
  268. text = text.replace("${BUILD_VERSION}", version)
  269. text = text.replace("${BUILD_DATE}", date_str)
  270. if text is not None:
  271. with open(pyfile, "w", encoding="utf-8") as f:
  272. f.write(text)
  273. print(f"Updated {pyfile}: version={version}, date={date_str}")
  274. else:
  275. exit(1)
  276. def replace_links_for_release_in_file(file_path, version, label=None, tag=None):
  277. """
  278. 将指定 Markdown 文件中的 "latest" 等动态链接替换为给定版本,便于发布归档。
  279. """
  280. if not os.path.exists(file_path):
  281. print(f"File not found: {file_path}")
  282. return False
  283. with open(file_path, "r", encoding="utf-8") as f:
  284. content = f.read()
  285. # Determine the tag string to use for GitHub/Docker (prefer provided tag, else add 'v' to version)
  286. tag_str = (
  287. tag or os.environ.get("GITHUB_REF_NAME") or ("v" + version if not str(version).startswith("v") else version)
  288. )
  289. # shields.io static badge escaping: '-' -> '--', '_' -> '__'
  290. def _shields_escape(text):
  291. return str(text).replace("-", "--").replace("_", "__")
  292. # GitHub releases download links -> pin to tag
  293. content = re.sub(
  294. r"https://github\.com/NewFuture/DDNS/releases/latest/download/",
  295. "https://github.com/NewFuture/DDNS/releases/download/{}/".format(tag_str),
  296. content,
  297. )
  298. # GitHub releases page -> pin to tag
  299. content = re.sub(
  300. r"https://github\.com/NewFuture/DDNS/releases/latest",
  301. "https://github.com/NewFuture/DDNS/releases/tag/{}".format(tag_str),
  302. content,
  303. )
  304. # Docker tags from latest to a pinned tag
  305. content = re.sub(r"docker pull ([^:\s]+):latest", "docker pull \\1:{}".format(tag_str), content)
  306. # Docker image references in run/create/examples: pin ghcr.io/newfuture/ddns:latest and newfuture/ddns:latest
  307. content = re.sub(r"(ghcr\.io/newfuture/ddns|newfuture/ddns):latest", r"\1:{}".format(tag_str), content)
  308. # PyPI project page -> pin to version page
  309. content = re.sub(
  310. r"https://pypi\.org/project/ddns(?:/latest)?(?=[\s\)])",
  311. "https://pypi.org/project/ddns/{}".format(version),
  312. content,
  313. )
  314. # Shield.io badges - Docker version badge (preserve query string)
  315. content = re.sub(
  316. r"(https://img\.shields\.io/docker/v/newfuture/ddns/)latest(\?[^)\s]*)?", r"\1{}\2".format(tag_str), content
  317. )
  318. # Simple pin for GitHub release badge -> static text with tag
  319. content = re.sub(
  320. r"https://img\.shields\.io/github/v/release/[^)\s]+",
  321. "https://img.shields.io/badge/DDNS-{}-black?logo=github&style=for-the-badge&label=DDNS".format(
  322. _shields_escape(tag_str)
  323. ),
  324. content,
  325. )
  326. # Simple pin for PyPI version badge -> static text with version
  327. content = re.sub(
  328. r"https://img\.shields\.io/pypi/v/ddns[^)\s]*",
  329. "https://img.shields.io/badge/PyPI-{}-blue?logo=python&style=for-the-badge".format(_shields_escape(version)),
  330. content,
  331. )
  332. # GitHub archive links -> pin to tag
  333. content = re.sub(
  334. r"https://github\.com/NewFuture/DDNS/archive/refs/tags/latest\.(zip|tar\.gz)",
  335. "https://github.com/NewFuture/DDNS/archive/refs/tags/{}.\\1".format(tag_str),
  336. content,
  337. )
  338. # PIP install commands -> pin to exact version (handle optional -U)
  339. content = re.sub(r"pip install -U ddns(?!=)", "pip install -U ddns=={}".format(version), content)
  340. content = re.sub(r"pip install ddns(?!=)", "pip install ddns=={}".format(version), content)
  341. # One-click install script examples: pin 'latest' to specific tag (vX.Y.Z)
  342. content = re.sub(r"(install\.sh \| sh -s --)\s+latest", r"\1 {}".format(tag_str), content, flags=re.IGNORECASE)
  343. with open(file_path, "w", encoding="utf-8") as f:
  344. f.write(content)
  345. name = label or os.path.basename(file_path)
  346. print("Updated {} links for release version: {}".format(name, version))
  347. return True
  348. def main():
  349. """
  350. 遍历所有py文件并替换兼容导入,同时更新nuitka版本号
  351. 支持参数:
  352. - version: 只更新版本号
  353. - release: 更新版本号并修改release.md链接为发布版本
  354. """
  355. if len(sys.argv) > 2:
  356. print(f"unknown arguments: {sys.argv}")
  357. exit(1)
  358. mode = sys.argv[1].lower() if len(sys.argv) > 1 else "default"
  359. version = resolve_version(mode)
  360. if mode not in ["version", "release", "default", "docker"]:
  361. print(f"unknown mode: {mode}")
  362. print("Usage: python patch.py [version|release|docker]")
  363. exit(1)
  364. elif mode == "release":
  365. # 同步修改 doc/release.md 的版本与链接
  366. release_md_path = os.path.join(ROOT, "doc", "release.md")
  367. if os.path.exists(release_md_path):
  368. replace_links_for_release_in_file(
  369. release_md_path, version, label="doc/release.md", tag=os.environ.get("GITHUB_REF_NAME")
  370. )
  371. exit(0)
  372. date_str = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
  373. print(f"Version: {version}")
  374. print(f"Date: {date_str}")
  375. # 修改__init__.py 中的 __version__
  376. replace_version_and_date(init_py_path, version, date_str)
  377. if mode == "version":
  378. # python version only
  379. exit(0)
  380. # 默认模式:继续执行原有逻辑
  381. run_py_path = os.path.join(ROOT, "run.py")
  382. update_nuitka_version(run_py_path, version)
  383. add_nuitka_file_description(run_py_path)
  384. add_nuitka_windows_unbuffered(run_py_path)
  385. # add_nuitka_include_modules(run_py_path)
  386. # 检测Docker环境并移除scheduler
  387. if mode == "docker":
  388. print("Detected Docker environment, removing scheduler components...")
  389. remove_scheduler_for_docker()
  390. changed_files = 0
  391. for dirpath, _, filenames in os.walk(ROOT):
  392. for fname in filenames:
  393. if fname.endswith(".py"):
  394. fpath = os.path.join(dirpath, fname)
  395. remove_python2_compatibility(fpath)
  396. changed_files += 1
  397. print("done")
  398. print(f"Total processed files: {changed_files}")
  399. if __name__ == "__main__":
  400. main()