Skip to content

Commit a9a857e

Browse files
committed
Fix test_restore_project PermissionError on Windows (#325)
On Windows, TemporaryDirectory cleanup fails with WinError 32 when databases.db is transiently locked (e.g. by antivirus) immediately after being extracted from the tar backup. - Add _rmtree_robust() that retries shutil.rmtree up to 5 times with exponential backoff on Windows PermissionError - Restructure restore_project_directory so the temp dir is deleted as soon as shutil.copytree completes; metadata and switch logic move after cleanup, reducing the window during which files are held open - Fix unclosed file handle in _restore_project_metadata (open() → with)
1 parent 39e72e5 commit a9a857e

1 file changed

Lines changed: 36 additions & 14 deletions

File tree

bw2io/backup.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import json
44
import os
55
import shutil
6+
import sys
67
import tarfile
78
import tempfile
9+
import time
810
from pathlib import Path
911
from typing import Optional, Union
1012

@@ -14,6 +16,26 @@
1416
_METADATA_FIELDS = {"is_sourced", "revision", "data", "full_hash"}
1517

1618

19+
def _rmtree_robust(path: Path) -> None:
20+
"""Remove a directory tree, retrying on Windows if files are briefly locked.
21+
22+
On Windows, SQLite files extracted from a tarball can be transiently locked
23+
by the OS (e.g. antivirus scanning) immediately after being written. Retry
24+
with exponential backoff before giving up and re-raising.
25+
"""
26+
if sys.platform != "win32":
27+
shutil.rmtree(path)
28+
return
29+
for attempt in range(5):
30+
try:
31+
shutil.rmtree(path)
32+
return
33+
except PermissionError:
34+
if attempt == 4:
35+
raise
36+
time.sleep(0.1 * (2**attempt))
37+
38+
1739
def _add_project_metadata() -> None:
1840
fp = projects.dir / "project-metadata.json"
1941
data = {
@@ -34,7 +56,8 @@ def _remove_project_metadata() -> None:
3456
def _restore_project_metadata() -> None:
3557
if not (projects.dir / "project-metadata.json").is_file():
3658
return
37-
metadata = json.load(open(projects.dir / "project-metadata.json", encoding="utf-8"))
59+
with open(projects.dir / "project-metadata.json", encoding="utf-8") as f:
60+
metadata = json.load(f)
3861
for field in _METADATA_FIELDS:
3962
if field in metadata:
4063
setattr(projects.dataset, field, metadata[field])
@@ -258,22 +281,21 @@ def get_project_name(fp):
258281
f"Project {project_name} already exists, set `overwrite_existing=True` to overwrite"
259282
)
260283

261-
with tempfile.TemporaryDirectory() as td:
262-
extracted_path = _extract_single_directory_tarball(filepath=fp, output_dir=td)
284+
_from_project_name = projects.current
285+
projects.set_current(project_name, update=False)
263286

264-
_from_project_name = projects.current
265-
projects.set_current(project_name, update=False)
287+
td = Path(tempfile.mkdtemp())
288+
try:
289+
extracted_path = _extract_single_directory_tarball(filepath=fp_path, output_dir=td)
266290
shutil.copytree(extracted_path, projects.dir, dirs_exist_ok=True)
291+
finally:
292+
_rmtree_robust(td)
267293

268-
_restore_project_metadata()
269-
_remove_project_metadata()
270-
271-
if not switch:
272-
projects.set_current(_from_project_name)
273-
274-
print(f"Restored project: {project_name}")
294+
_restore_project_metadata()
295+
_remove_project_metadata()
275296

276-
if switch:
277-
projects.set_current(project_name, update=False)
297+
if not switch:
298+
projects.set_current(_from_project_name)
278299

300+
print(f"Restored project: {project_name}")
279301
return project_name

0 commit comments

Comments
 (0)