download_kernel_archives.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import argparse
  2. import base64
  3. import json
  4. import os
  5. import shutil
  6. import subprocess
  7. import tempfile
  8. import threading
  9. import time
  10. import urllib.parse
  11. import urllib.request
  12. import xml.etree.ElementTree as ET
  13. from concurrent.futures import ThreadPoolExecutor
  14. from typing import Any
  15. from urllib.error import HTTPError
  16. from urllib.request import Request
  17. from urllib.request import urlopen
  18. def parse_args() -> argparse.Namespace:
  19. parser = argparse.ArgumentParser()
  20. parser.add_argument("--android-version", required=True)
  21. parser.add_argument("--kernel-version", required=True)
  22. parser.add_argument("--os-patch-level", required=True)
  23. return parser.parse_args()
  24. def write_github_output(name: str, value: str) -> None:
  25. path = os.environ.get("GITHUB_OUTPUT")
  26. if not path:
  27. return
  28. with open(path, "a", encoding="utf-8") as f:
  29. f.write(f"{name}={value}\n")
  30. def main() -> int:
  31. args = parse_args()
  32. formatted_branch = f"{args.android_version}-{args.kernel_version}-{args.os_patch_level}"
  33. manifest_branch = f"common-{formatted_branch}"
  34. manifest_ref = f"refs/heads/{manifest_branch}"
  35. manifest_base = f"https://android.googlesource.com/kernel/manifest/+/{manifest_ref}/"
  36. manifest_origin = f"{urllib.parse.urlparse(manifest_base).scheme}://{urllib.parse.urlparse(manifest_base).netloc}"
  37. target_repo = os.environ.get("GITHUB_REPOSITORY", "")
  38. github_token = os.environ.get("GITHUB_TOKEN", "")
  39. toolchain_map = {
  40. "clang/host/linux-x86": "clang",
  41. "prebuilts/rust": "rust",
  42. "prebuilts/clang-tools": "clang-tools",
  43. "prebuilts/build-tools": "build-tools",
  44. }
  45. release_assets_lock = threading.Lock()
  46. release_assets_cache: list[dict[str, Any]] | None = None
  47. release_info_lock = threading.Lock()
  48. release_info_cache: dict[str, Any] | None = None
  49. upload_locks_guard = threading.Lock()
  50. upload_locks: dict[tuple[str, str], threading.Lock] = {}
  51. def is_deprecated_branch(branch: str) -> bool:
  52. try:
  53. out = subprocess.check_output(
  54. ["git", "ls-remote", "https://android.googlesource.com/kernel/common", branch],
  55. text=True,
  56. stderr=subprocess.STDOUT,
  57. timeout=60,
  58. )
  59. return "deprecated" in out
  60. except Exception:
  61. return False
  62. deprecated = is_deprecated_branch(formatted_branch)
  63. write_github_output("deprecated", str(deprecated).lower())
  64. print(f"Manifest branch: {manifest_branch}")
  65. print(f"Deprecated: {deprecated}")
  66. def fetch_gitiles_file_text(file_name: str) -> str:
  67. url = f"{manifest_base}{file_name}?format=TEXT"
  68. req = Request(url, headers={"User-Agent": "actions-download-kernel"})
  69. with urlopen(req, timeout=60) as resp:
  70. data = resp.read()
  71. decoded = base64.b64decode(data)
  72. text = decoded.decode("utf-8", errors="replace")
  73. if deprecated:
  74. text = text.replace(f"\"{formatted_branch}\"", f"\"deprecated/{formatted_branch}\"")
  75. return text
  76. def load_manifest_with_includes(entry_file: str) -> tuple[ET.Element, list[tuple[str, ET.Element]]]:
  77. seen_files: set[str] = set()
  78. to_process = [entry_file]
  79. combined = ET.Element("manifest")
  80. combined_default: ET.Element | None = None
  81. combined_remotes: dict[str, ET.Element] = {}
  82. projects: list[ET.Element] = []
  83. link_copy: list[tuple[str, ET.Element]] = []
  84. while to_process:
  85. file_name = to_process.pop()
  86. if file_name in seen_files:
  87. continue
  88. seen_files.add(file_name)
  89. xml_text = fetch_gitiles_file_text(file_name)
  90. root = ET.fromstring(xml_text)
  91. for include in root.findall("include"):
  92. inc_name = include.get("name")
  93. if inc_name:
  94. to_process.append(inc_name)
  95. for remote in root.findall("remote"):
  96. remote_name = remote.get("name")
  97. if remote_name and remote_name not in combined_remotes:
  98. combined_remotes[remote_name] = remote
  99. if combined_default is None:
  100. d = root.find("default")
  101. if d is not None:
  102. combined_default = d
  103. for project in root.findall("project"):
  104. projects.append(project)
  105. path = project.get("path", project.get("name") or "")
  106. for child in list(project):
  107. if child.tag in ("linkfile", "copyfile"):
  108. link_copy.append((path, child))
  109. for r in combined_remotes.values():
  110. combined.append(r)
  111. if combined_default is not None:
  112. combined.append(combined_default)
  113. for p in projects:
  114. combined.append(p)
  115. return combined, link_copy
  116. manifest_root, link_copy = load_manifest_with_includes("default.xml")
  117. def resolve_remote_fetch(fetch: str) -> str:
  118. fetch = fetch.strip()
  119. if fetch.startswith("https://") or fetch.startswith("http://"):
  120. return fetch.rstrip("/")
  121. base = f"{manifest_origin}/"
  122. resolved = urllib.parse.urljoin(base, f"{fetch.rstrip('/')}/")
  123. return resolved.rstrip("/")
  124. remotes = {
  125. r.get("name"): resolve_remote_fetch(r.get("fetch") or "")
  126. for r in manifest_root.findall("remote")
  127. if r.get("name") and r.get("fetch") is not None
  128. }
  129. default = manifest_root.find("default")
  130. def_remote = default.get("remote") if default is not None else None
  131. def_rev = default.get("revision") if default is not None else None
  132. def get_toolchain_label(project_name: str) -> str | None:
  133. for key, value in toolchain_map.items():
  134. if key in project_name:
  135. return value
  136. return None
  137. def github_api_get_json(url: str) -> object:
  138. headers = {
  139. "Accept": "application/vnd.github.v3+json",
  140. "User-Agent": "actions-download-kernel",
  141. }
  142. if github_token:
  143. headers["Authorization"] = f"token {github_token}"
  144. req = Request(url, headers=headers)
  145. with urlopen(req, timeout=60) as resp:
  146. data = resp.read()
  147. return json.loads(data.decode("utf-8", errors="replace"))
  148. def github_api_post_json(url: str, payload: dict[str, Any]) -> object:
  149. headers = {
  150. "Accept": "application/vnd.github.v3+json",
  151. "User-Agent": "actions-download-kernel",
  152. "Content-Type": "application/json",
  153. }
  154. if github_token:
  155. headers["Authorization"] = f"token {github_token}"
  156. data = json.dumps(payload).encode("utf-8")
  157. req = Request(url, headers=headers, data=data, method="POST")
  158. with urlopen(req, timeout=60) as resp:
  159. body = resp.read()
  160. return json.loads(body.decode("utf-8", errors="replace"))
  161. def get_toolchain_release_assets() -> list[dict[str, Any]]:
  162. nonlocal release_assets_cache
  163. with release_assets_lock:
  164. if release_assets_cache is not None:
  165. return release_assets_cache
  166. if not target_repo:
  167. release_assets_cache = []
  168. return release_assets_cache
  169. url = f"https://api.github.com/repos/{target_repo}/releases?per_page=100"
  170. try:
  171. releases = github_api_get_json(url)
  172. if not isinstance(releases, list):
  173. release_assets_cache = []
  174. return release_assets_cache
  175. release = next(
  176. (
  177. r
  178. for r in releases
  179. if isinstance(r, dict)
  180. and (r.get("name") == "Toolchains Mirror Cache" or r.get("tag_name") == "toolchain-cache")
  181. ),
  182. None,
  183. )
  184. if not release:
  185. release_assets_cache = []
  186. return release_assets_cache
  187. assets = release.get("assets", [])
  188. release_assets_cache = assets if isinstance(assets, list) else []
  189. return release_assets_cache
  190. except Exception:
  191. release_assets_cache = []
  192. return release_assets_cache
  193. def get_or_create_toolchain_release() -> dict[str, Any] | None:
  194. nonlocal release_info_cache, release_assets_cache
  195. with release_info_lock:
  196. if release_info_cache is not None:
  197. return release_info_cache
  198. if not target_repo or not github_token:
  199. release_info_cache = None
  200. return release_info_cache
  201. try:
  202. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  203. release = github_api_get_json(url)
  204. if isinstance(release, dict):
  205. release_info_cache = release
  206. assets = release.get("assets", [])
  207. with release_assets_lock:
  208. release_assets_cache = assets if isinstance(assets, list) else []
  209. return release_info_cache
  210. except Exception:
  211. pass
  212. try:
  213. create_url = f"https://api.github.com/repos/{target_repo}/releases"
  214. release = github_api_post_json(
  215. create_url,
  216. {
  217. "tag_name": "toolchain-cache",
  218. "name": "Toolchains Mirror Cache",
  219. "draft": False,
  220. "prerelease": False,
  221. },
  222. )
  223. if isinstance(release, dict):
  224. release_info_cache = release
  225. assets = release.get("assets", [])
  226. with release_assets_lock:
  227. release_assets_cache = assets if isinstance(assets, list) else []
  228. return release_info_cache
  229. except Exception:
  230. try:
  231. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  232. release = github_api_get_json(url)
  233. if isinstance(release, dict):
  234. release_info_cache = release
  235. assets = release.get("assets", [])
  236. with release_assets_lock:
  237. release_assets_cache = assets if isinstance(assets, list) else []
  238. return release_info_cache
  239. except Exception:
  240. release_info_cache = None
  241. return release_info_cache
  242. release_info_cache = None
  243. return release_info_cache
  244. def download_github_release_asset(api_url: str, out_path: str) -> None:
  245. headers = {
  246. "Accept": "application/octet-stream",
  247. "User-Agent": "actions-download-kernel",
  248. }
  249. if github_token:
  250. headers["Authorization"] = f"token {github_token}"
  251. req = Request(api_url, headers=headers)
  252. with urlopen(req, timeout=120) as resp, open(out_path, "wb") as f:
  253. shutil.copyfileobj(resp, f, length=1024 * 1024)
  254. def run_curl_download(url: str, out_path: str) -> None:
  255. subprocess.run(
  256. ["curl", "-LfsS", "--retry", "5", "--connect-timeout", "30", "-o", out_path, url],
  257. check=True,
  258. text=True,
  259. )
  260. def extract_tar_gz(archive_path: str, dest_dir: str, strip_components: int) -> None:
  261. cmd = ["tar", "-xzf", archive_path, "-C", dest_dir]
  262. if strip_components > 0:
  263. cmd.extend(["--strip-components", str(strip_components)])
  264. subprocess.run(cmd, check=True, text=True)
  265. def upload_release_asset(upload_url_template: str, file_path: str, asset_name: str) -> None:
  266. upload_url = upload_url_template.split("{", 1)[0]
  267. url = f"{upload_url}?name={urllib.parse.quote(asset_name, safe='')}"
  268. tmp_fd, tmp_resp = tempfile.mkstemp(prefix="gh-release-upload-", suffix=".json")
  269. os.close(tmp_fd)
  270. cmd = [
  271. "curl",
  272. "-LsS",
  273. "-X",
  274. "POST",
  275. "-H",
  276. "Accept: application/vnd.github.v3+json",
  277. "-H",
  278. "Content-Type: application/octet-stream",
  279. "-H",
  280. "User-Agent: actions-download-kernel",
  281. "-o",
  282. tmp_resp,
  283. "-w",
  284. "%{http_code}",
  285. ]
  286. if github_token:
  287. cmd.extend(["-H", f"Authorization: token {github_token}"])
  288. cmd.extend(["--data-binary", f"@{file_path}", url])
  289. try:
  290. res = subprocess.run(cmd, check=True, text=True, capture_output=True)
  291. code = (res.stdout or "").strip()
  292. if code in {"200", "201"}:
  293. return
  294. if code == "422":
  295. return
  296. body = ""
  297. try:
  298. with open(tmp_resp, "r", encoding="utf-8", errors="replace") as f:
  299. body = f.read()
  300. except Exception:
  301. body = ""
  302. raise RuntimeError(f"Release asset upload failed (HTTP {code}): {body}")
  303. finally:
  304. if os.path.exists(tmp_resp):
  305. os.remove(tmp_resp)
  306. def ensure_toolchain_cached(label: str, rev: str, src_dir: str) -> None:
  307. nonlocal release_assets_cache
  308. if not github_token or not target_repo:
  309. return
  310. cache_key = (label, rev)
  311. with upload_locks_guard:
  312. lock = upload_locks.get(cache_key)
  313. if lock is None:
  314. lock = threading.Lock()
  315. upload_locks[cache_key] = lock
  316. with lock:
  317. try:
  318. base_filename = f"{label}-{rev}.tar.gz"
  319. assets = get_toolchain_release_assets()
  320. for a in assets:
  321. if isinstance(a, dict):
  322. asset_name = a.get("name")
  323. if isinstance(asset_name, str) and asset_name.startswith(base_filename):
  324. return
  325. release = get_or_create_toolchain_release()
  326. if not release:
  327. return
  328. upload_url_template = release.get("upload_url")
  329. if not isinstance(upload_url_template, str) or not upload_url_template:
  330. return
  331. tmp_dir = tempfile.mkdtemp(prefix="toolchain-cache-")
  332. try:
  333. archive_path = os.path.join(tmp_dir, base_filename)
  334. subprocess.run(
  335. ["tar", "-I", "gzip -1", "-cf", archive_path, "-C", src_dir, "."],
  336. check=True,
  337. text=True,
  338. )
  339. size = os.path.getsize(archive_path)
  340. max_part = 1900 * 1024 * 1024
  341. if size <= max_part:
  342. upload_release_asset(upload_url_template, archive_path, base_filename)
  343. else:
  344. subprocess.run(
  345. ["split", "-b", str(max_part), "-d", "-a", "2", archive_path, f"{archive_path}.part"],
  346. check=True,
  347. text=True,
  348. )
  349. for name in sorted(os.listdir(tmp_dir)):
  350. if name.startswith(f"{base_filename}.part"):
  351. part_path = os.path.join(tmp_dir, name)
  352. upload_release_asset(upload_url_template, part_path, name)
  353. with release_assets_lock:
  354. release_assets_cache = None
  355. finally:
  356. shutil.rmtree(tmp_dir, ignore_errors=True)
  357. except Exception:
  358. return
  359. def try_extract_toolchain_cache(project_name: str, rev: str, dest_dir: str, strip_components: int) -> bool:
  360. label = get_toolchain_label(project_name)
  361. if not label:
  362. return False
  363. assets = get_toolchain_release_assets()
  364. if not assets:
  365. return False
  366. base_filename = f"{label}-{rev}.tar.gz"
  367. matching: list[tuple[str, str]] = []
  368. for a in assets:
  369. if not isinstance(a, dict):
  370. continue
  371. asset_name = a.get("name")
  372. asset_url = a.get("url")
  373. if isinstance(asset_name, str) and isinstance(asset_url, str) and asset_name.startswith(base_filename):
  374. matching.append((asset_name, asset_url))
  375. if not matching:
  376. return False
  377. matching.sort(key=lambda x: x[0])
  378. part_paths: list[str] = []
  379. try:
  380. for asset_name, asset_url in matching:
  381. part_path = os.path.abspath(asset_name)
  382. download_github_release_asset(asset_url, part_path)
  383. part_paths.append(part_path)
  384. if len(part_paths) == 1 and part_paths[0].endswith(".tar.gz"):
  385. extract_tar_gz(part_paths[0], dest_dir, strip_components)
  386. else:
  387. tmp_fd, tmp_path = tempfile.mkstemp(prefix="toolchain-", suffix=".tar.gz")
  388. os.close(tmp_fd)
  389. try:
  390. with open(tmp_path, "wb") as out_f:
  391. for p in part_paths:
  392. with open(p, "rb") as in_f:
  393. shutil.copyfileobj(in_f, out_f, length=1024 * 1024)
  394. extract_tar_gz(tmp_path, dest_dir, strip_components)
  395. finally:
  396. if os.path.exists(tmp_path):
  397. os.remove(tmp_path)
  398. return True
  399. except Exception:
  400. return False
  401. finally:
  402. for p in part_paths:
  403. if os.path.exists(p):
  404. os.remove(p)
  405. def url_quote_rev(rev: str) -> str:
  406. return urllib.parse.quote(rev, safe="")
  407. def googlesource_archive_urls(repo_url: str, rev: str) -> list[str]:
  408. qrev = url_quote_rev(rev)
  409. if rev.startswith("refs/"):
  410. return [f"{repo_url}/+archive/{qrev}.tar.gz"]
  411. return [
  412. f"{repo_url}/+archive/{url_quote_rev(f'refs/heads/{rev}')}.tar.gz",
  413. f"{repo_url}/+archive/{url_quote_rev(f'refs/tags/{rev}')}.tar.gz",
  414. f"{repo_url}/+archive/{qrev}.tar.gz",
  415. ]
  416. def github_archive_urls(repo_url: str, rev: str) -> list[str]:
  417. if rev.startswith("refs/"):
  418. return [f"{repo_url}/archive/{rev}.tar.gz"]
  419. return [
  420. f"{repo_url}/archive/refs/heads/{rev}.tar.gz",
  421. f"{repo_url}/archive/refs/tags/{rev}.tar.gz",
  422. f"{repo_url}/archive/{rev}.tar.gz",
  423. ]
  424. def gitlab_archive_urls(repo_url: str, rev: str, repo_basename: str) -> list[str]:
  425. qrev = urllib.parse.quote(rev, safe="")
  426. return [f"{repo_url}/-/archive/{qrev}/{repo_basename}-{qrev}.tar.gz"]
  427. def build_project_urls(base_url: str, name: str, rev: str) -> tuple[list[str], int]:
  428. repo_url = f"{base_url}/{name}"
  429. if "github.com" in base_url:
  430. return github_archive_urls(repo_url, rev), 1
  431. if "googlesource.com" in base_url or "android.googlesource.com" in base_url:
  432. return googlesource_archive_urls(repo_url, rev), 0
  433. if "git.codelinaro.org" in base_url or "gitlab" in base_url:
  434. repo_basename = name.rstrip("/").split("/")[-1]
  435. return gitlab_archive_urls(repo_url, rev, repo_basename), 1
  436. return [], 0
  437. def sync_project(task: tuple[str, str, str, str]) -> dict[str, Any]:
  438. name, path, rev, base_url = task
  439. start_time = time.time()
  440. dest_dir = os.path.abspath(path if path not in ("./", ".") else ".")
  441. os.makedirs(dest_dir, exist_ok=True)
  442. try:
  443. urls, strip_components = build_project_urls(base_url, name, rev)
  444. if not urls:
  445. return {
  446. "ok": False,
  447. "name": name,
  448. "path": path,
  449. "rev": rev,
  450. "base_url": base_url,
  451. "error": "no supported remote",
  452. "urls": [],
  453. }
  454. if try_extract_toolchain_cache(name, rev, dest_dir, strip_components):
  455. duration = time.time() - start_time
  456. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  457. return {"ok": True}
  458. tmp_fd, tmp_path = tempfile.mkstemp(prefix="kernel-src-", suffix=".tar.gz")
  459. os.close(tmp_fd)
  460. try:
  461. last_err: str | None = None
  462. for url in urls:
  463. try:
  464. run_curl_download(url, tmp_path)
  465. extract_tar_gz(tmp_path, dest_dir, strip_components)
  466. label = get_toolchain_label(name)
  467. if label:
  468. ensure_toolchain_cached(label, rev, dest_dir)
  469. duration = time.time() - start_time
  470. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  471. return {"ok": True}
  472. except subprocess.CalledProcessError as e:
  473. last_err = str(e)
  474. continue
  475. return {
  476. "ok": False,
  477. "name": name,
  478. "path": path,
  479. "rev": rev,
  480. "base_url": base_url,
  481. "error": last_err or "download/extract failed",
  482. "urls": urls,
  483. }
  484. finally:
  485. if os.path.exists(tmp_path):
  486. os.remove(tmp_path)
  487. except Exception as e:
  488. return {
  489. "ok": False,
  490. "name": name,
  491. "path": path,
  492. "rev": rev,
  493. "base_url": base_url,
  494. "error": f"unexpected exception: {e}",
  495. "urls": [],
  496. }
  497. def ensure_build_tools_python(kernel_root: str) -> None:
  498. build_tools_dir = os.path.join(kernel_root, "prebuilts", "build-tools")
  499. expected = os.path.join(build_tools_dir, "path", "linux-x86", "python3")
  500. if os.path.exists(expected):
  501. return
  502. os.makedirs(os.path.dirname(expected), exist_ok=True)
  503. candidate: str | None = None
  504. for root_dir, _, files in os.walk(build_tools_dir):
  505. if "python3" in files:
  506. candidate = os.path.join(root_dir, "python3")
  507. break
  508. if candidate is None:
  509. candidate = shutil.which("python3") or "/usr/bin/python3"
  510. try:
  511. if os.path.lexists(expected):
  512. os.remove(expected)
  513. rel_target = os.path.relpath(candidate, os.path.dirname(expected))
  514. os.symlink(rel_target, expected)
  515. except Exception:
  516. return
  517. sync_tasks: list[tuple[str, str, str, str]] = []
  518. for project in manifest_root.findall("project"):
  519. name = project.get("name")
  520. if not name:
  521. continue
  522. path = project.get("path", name)
  523. remote_name = project.get("remote", def_remote)
  524. if not remote_name:
  525. continue
  526. base_url = remotes.get(remote_name)
  527. if not base_url:
  528. continue
  529. rev = project.get("revision", def_rev)
  530. if not rev:
  531. continue
  532. sync_tasks.append((name, path, rev, base_url))
  533. max_workers = min(32, (os.cpu_count() or 2) * 2)
  534. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  535. results: list[dict[str, Any]] = list(executor.map(sync_project, sync_tasks))
  536. failed = [r for r in results if not r.get("ok")]
  537. if failed:
  538. for r in failed[:25]:
  539. print(
  540. f"[FAIL] {r.get('name')} -> {r.get('path')} rev={r.get('rev')} remote={r.get('base_url')} err={r.get('error')}"
  541. )
  542. for u in (r.get("urls") or [])[:5]:
  543. print(f" url={u}")
  544. return 1
  545. ensure_build_tools_python(os.getcwd())
  546. for project_path, child in link_copy:
  547. src_rel = child.get("src")
  548. dest_rel = child.get("dest")
  549. if not src_rel or not dest_rel:
  550. continue
  551. src_path = os.path.join(os.getcwd(), project_path, src_rel)
  552. dest_path = os.path.join(os.getcwd(), dest_rel)
  553. os.makedirs(os.path.dirname(dest_path), exist_ok=True)
  554. if child.tag == "linkfile":
  555. if os.path.lexists(dest_path):
  556. os.remove(dest_path)
  557. rel_target = os.path.relpath(src_path, os.path.dirname(dest_path))
  558. os.symlink(rel_target, dest_path)
  559. elif child.tag == "copyfile":
  560. shutil.copy2(src_path, dest_path)
  561. return 0
  562. if __name__ == "__main__":
  563. raise SystemExit(main())