Skip to content

Commit a3e0ee8

Browse files
authored
fix: accelerate archives with pigz when available (#14)
Use pigz for archive operations when available, stage directory unpack on the target filesystem, and reuse unpacked Mathlib .lake caches for offline setup.
1 parent 3939a36 commit a3e0ee8

5 files changed

Lines changed: 80 additions & 24 deletions

File tree

leanup/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = """Lean-zh Community"""
44
__email__ = 'leanprover@outlook.com'
5-
__version__ = '0.3.0'
5+
__version__ = '0.3.1'
66

77
from .repo import (
88
RepoManager,

leanup/cli/cache_ops.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import requests
99

1010
from leanup.const import LEANUP_CACHE_DIR
11-
from leanup.ops.environment import safe_extract, tar_directory
11+
from leanup.ops.environment import extract_tar_gz, tar_directory
1212
from leanup.paths import cache_dir as leanup_cache_dir
1313
from leanup.repo.cache_server import run_cache_server
1414
from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version, remove_path
@@ -31,9 +31,9 @@ def _extract_lake_archive(archive: Path, target_lake: Path) -> Path:
3131
raise ValueError(f"Archive not found: {archive}")
3232
parent = target_lake.parent
3333
parent.mkdir(parents=True, exist_ok=True)
34-
with tempfile.TemporaryDirectory(prefix="leanup-lake-unpack-") as work:
34+
with tempfile.TemporaryDirectory(prefix=".leanup-lake-unpack.", dir=parent) as work:
3535
temp_root = Path(work)
36-
safe_extract(archive, temp_root)
36+
extract_tar_gz(archive, temp_root)
3737
extracted = temp_root / ".lake"
3838
if not extracted.exists() or not extracted.is_dir():
3939
raise ValueError(f"Archive does not contain top-level .lake/ directory: {archive}")

leanup/ops/environment.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pathlib import Path
44
import os
5+
import shutil
56
import subprocess
67
import tarfile
78
import tempfile
@@ -13,6 +14,27 @@
1314
from leanup.repo.mathlib_cache import normalize_lean_version, remove_path
1415

1516

17+
def has_parallel_gzip() -> bool:
18+
return shutil.which("pigz") is not None and shutil.which("tar") is not None
19+
20+
21+
def validate_archive_paths(archive: Path, target_dir: Path) -> None:
22+
target_dir = target_dir.resolve()
23+
with tarfile.open(archive, "r:gz") as tar:
24+
for member in tar.getmembers():
25+
member_path = (target_dir / member.name).resolve()
26+
if not str(member_path).startswith(str(target_dir)):
27+
raise ValueError(f"Archive contains unsafe path: {member.name}")
28+
29+
30+
def extract_tar_gz(archive: Path, target_dir: Path) -> None:
31+
validate_archive_paths(archive, target_dir)
32+
if has_parallel_gzip():
33+
subprocess.run(["tar", "-I", "pigz", "-xf", str(archive), "-C", str(target_dir)], check=True)
34+
return
35+
safe_extract(archive, target_dir)
36+
37+
1638
def download_to(url: str, output_file: Path) -> Path:
1739
output_file.parent.mkdir(parents=True, exist_ok=True)
1840
with tempfile.NamedTemporaryFile(dir=output_file.parent, prefix=f".{output_file.name}.", suffix=".tmp", delete=False) as handle:
@@ -61,14 +83,25 @@ def tar_directory(source_dir: Path, arcname: str, output_file: Path, exclude: se
6183
with tempfile.NamedTemporaryFile(dir=output_file.parent, prefix=f".{output_file.name}.", suffix=".tmp", delete=False) as handle:
6284
temp_output = Path(handle.name)
6385
try:
64-
with tarfile.open(temp_output, "w:gz", dereference=False) as tar:
65-
if exclude:
66-
for child in sorted(source_dir.iterdir()):
67-
if child.name in exclude:
68-
continue
69-
tar.add(child, arcname=f"{arcname}/{child.name}", recursive=True)
70-
else:
71-
tar.add(source_dir, arcname=arcname, recursive=True)
86+
if exclude or not has_parallel_gzip():
87+
with tarfile.open(temp_output, "w:gz", dereference=False) as tar:
88+
if exclude:
89+
for child in sorted(source_dir.iterdir()):
90+
if child.name in exclude:
91+
continue
92+
tar.add(child, arcname=f"{arcname}/{child.name}", recursive=True)
93+
else:
94+
tar.add(source_dir, arcname=arcname, recursive=True)
95+
else:
96+
subprocess.run(
97+
["tar", "-I", "pigz", "-cf", str(temp_output), "-C", str(source_dir.parent), "--", source_dir.name],
98+
check=True,
99+
)
100+
# Preserve the requested archive root name. External tar is only used when arcname equals source name or .lake.
101+
if arcname != source_dir.name:
102+
remove_path(temp_output)
103+
with tarfile.open(temp_output, "w:gz", dereference=False) as tar:
104+
tar.add(source_dir, arcname=arcname, recursive=True)
72105
temp_output.replace(output_file)
73106
return output_file
74107
except Exception:
@@ -117,9 +150,10 @@ def get_elan(server: str | None = None) -> Path:
117150
def unpack_elan(archive: Path | None = None, target_home: Path | None = None) -> Path:
118151
archive_path = archive or elan_archive_path()
119152
target = target_home or elan_home()
120-
with tempfile.TemporaryDirectory(prefix="leanup-elan-unpack-") as work:
153+
target.parent.mkdir(parents=True, exist_ok=True)
154+
with tempfile.TemporaryDirectory(prefix=".leanup-elan-unpack.", dir=target.parent) as work:
121155
work_root = Path(work)
122-
safe_extract(archive_path, work_root)
156+
extract_tar_gz(archive_path, work_root)
123157
extracted = work_root / ".elan"
124158
if not extracted.exists():
125159
raise ValueError(f"Archive does not contain .elan/: {archive_path}")
@@ -160,9 +194,10 @@ def get_lean(version: str, server: str | None = None) -> Path:
160194
def unpack_lean(version: str, archive: Path | None = None, target_home: Path | None = None) -> Path:
161195
archive_path = archive or lean_archive_path(version)
162196
home = target_home or elan_home()
163-
with tempfile.TemporaryDirectory(prefix="leanup-lean-unpack-") as work:
197+
(home / "toolchains").mkdir(parents=True, exist_ok=True)
198+
with tempfile.TemporaryDirectory(prefix=".leanup-lean-unpack.", dir=home / "toolchains") as work:
164199
work_root = Path(work)
165-
safe_extract(archive_path, work_root)
200+
extract_tar_gz(archive_path, work_root)
166201
toolchains_root = work_root / ".elan" / "toolchains"
167202
candidates = [path for path in toolchains_root.iterdir() if path.is_dir()] if toolchains_root.exists() else []
168203
if len(candidates) != 1:

leanup/repo/project_setup.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version, remove_path
1313
from leanup.repo.manager import LeanRepo
1414
from leanup.utils.basic import working_directory
15+
from leanup.paths import cache_dir as leanup_cache_dir
1516
from leanup.utils.custom_logger import setup_logger
1617

1718
logger = setup_logger("project_setup")
@@ -108,10 +109,13 @@ def setup(self, config: SetupConfig) -> SetupResult:
108109
cache_dir = config.mathlib_cache_dir if config.mathlib else None
109110

110111
if config.mathlib and config.resolved_dependency_mode in {"symlink", "copy"}:
111-
logger.info("Checking reusable mathlib package cache")
112-
used_cache = self._prepare_mathlib_cache(config, project_dir)
113-
if used_cache:
114-
self._write_manifest_from_packages(config, project_dir)
112+
logger.info("Checking reusable local .lake cache")
113+
used_cache = self._prepare_local_lake_cache(config, project_dir)
114+
if not used_cache:
115+
logger.info("Checking reusable mathlib package cache")
116+
used_cache = self._prepare_mathlib_cache(config, project_dir)
117+
if used_cache:
118+
self._write_manifest_from_packages(config, project_dir)
115119

116120
if config.mathlib and self._should_run_lake_update(config, project_dir):
117121
logger.info("Running lake update")
@@ -365,6 +369,24 @@ def _verify_mathlib_project(self, project_dir: Path) -> None:
365369
if probe.exists():
366370
probe.unlink()
367371

372+
def _local_lake_cache_dir(self, lean_version: str) -> Path:
373+
return leanup_cache_dir() / "local" / "mathlib" / normalize_lean_version(lean_version) / ".lake"
374+
375+
def _prepare_local_lake_cache(self, config: SetupConfig, project_dir: Path) -> bool:
376+
lake_cache = self._local_lake_cache_dir(config.lean_version)
377+
if not lake_cache.exists():
378+
return False
379+
380+
project_lake = project_dir / ".lake"
381+
remove_path(project_lake)
382+
project_lake.parent.mkdir(parents=True, exist_ok=True)
383+
if config.resolved_dependency_mode == "symlink":
384+
project_lake.symlink_to(lake_cache, target_is_directory=True)
385+
else:
386+
shutil.copytree(lake_cache, project_lake, symlinks=True)
387+
self._write_manifest_from_packages(config, project_dir)
388+
return True
389+
368390
def _prepare_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> bool:
369391
cache_dir = self.cache_manager.ensure_local_cache(config.lean_version)
370392
if not cache_dir:

leanup/repo/toolchain_cache.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from leanup.const import LEANUP_CACHE_DIR
1313
from leanup.repo.elan import ElanManager
1414
from leanup.repo.mathlib_cache import normalize_lean_version, remove_path
15+
from leanup.ops.environment import extract_tar_gz
1516
from leanup.utils.custom_logger import setup_logger
1617

1718
logger = setup_logger("toolchain_cache")
@@ -101,8 +102,7 @@ def unpack_base_archive(self, archive_path: Optional[Path] = None) -> Path:
101102
archive_path = archive_path or self.get_base_archive_path()
102103
with tempfile.TemporaryDirectory(prefix=".elan-base.", dir=self.elan_home.parent) as work:
103104
temp_root = Path(work)
104-
with tarfile.open(archive_path, "r:gz") as tar:
105-
self._safe_extract(tar, temp_root)
105+
extract_tar_gz(archive_path, temp_root)
106106
extracted = temp_root / ".elan"
107107
if not extracted.exists():
108108
raise ValueError(f"Archive does not contain top-level .elan/ directory: {archive_path}")
@@ -136,8 +136,7 @@ def unpack_toolchain_archive(self, version: str, archive_path: Optional[Path] =
136136
archive_path = archive_path or self.get_toolchain_archive_path(version)
137137
with tempfile.TemporaryDirectory(prefix=".elan-toolchain.", dir=self.elan_home.parent) as work:
138138
temp_root = Path(work)
139-
with tarfile.open(archive_path, "r:gz") as tar:
140-
self._safe_extract(tar, temp_root)
139+
extract_tar_gz(archive_path, temp_root)
141140
toolchains_root = temp_root / ".elan" / "toolchains"
142141
toolchain_dirs = [path for path in toolchains_root.iterdir() if path.is_dir()] if toolchains_root.exists() else []
143142
if len(toolchain_dirs) != 1:

0 commit comments

Comments
 (0)