Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 61 additions & 12 deletions src/bonsai/scripts/dev_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Union
Expand Down Expand Up @@ -95,6 +96,44 @@ def find_bonsai_path() -> Union[Path, None]:
PYTHON_VERSION = "3.13" if BLENDER_VERSION_INT >= (5, 1) else "3.11"
PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages"

PARTIAL_LINK_ERROR = """
Failed while replacing '{path}'.

The extension is now only partially linked and Blender won't be able to import it until
this completes. This is usually a file lock - close Blender if it's running. An antivirus
scan of a freshly installed extension can also hold files for a while.

Re-running this script is safe and will finish the job.
"""


def remove_path(path: Path) -> None:
"""Remove `path` so it can be replaced by a symlink."""
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
# Check `is_symlink` in case if it's a broken symlink.
elif path.is_file() or path.is_symlink():
path.unlink()


def remove_path_with_retries(path: Path, attempts: int = 5, delay: float = 2.0) -> None:
"""`remove_path`, retrying while the files are still locked by another process.

`rmtree` is only reached on the first setup, when the extension binaries have just been
written and an antivirus scanner may still be holding them. On Windows that surfaces as
PermissionError (WinError 5, not the sharing violation you might expect) and usually
clears within a few seconds.
"""
for attempt in range(1, attempts + 1):
try:
remove_path(path)
return
except PermissionError:
if attempt == attempts:
raise
print(f"'{path}' is locked, retrying in {delay:.0f}s ({attempt}/{attempts - 1})...")
time.sleep(delay)


def main() -> None:
global REPO_PATH
Expand Down Expand Up @@ -137,14 +176,28 @@ def main() -> None:
path.unlink()
subprocess.check_call(("git", "checkout", "--", symlinks_glob), cwd=REPO_PATH)

# Note this has to stay ahead of the symlinking below: it's the step that gets the compiled
# wrapper out of site-packages before that folder is deleted and replaced by a symlink.
if args.skip_binaries:
print("Skipping copying compiled dependencies to the repo...")
else:
print("Copying compiled dependencies to the repo...")
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
# Since 0.9.0 the wrapper is no longer self-contained: it sits next to runtime
# libraries such as ifcopenshell.parse.dll and the per-schema
# ifcopenshell_parse_schema_ifc*.dll, which are loaded by name at runtime rather
# than linked. A "*_wrapper*" glob never matched those, so they were left behind
# and the wrapper came up without them, surfacing much later as
# "RuntimeError: No schema named IFC4". Select on file type instead.
library_suffixes = (".dll", ".pyd", ".so", ".dylib")
for path in PACKAGE_PATH.glob("ifcopenshell/*"):
if not path.is_file():
continue
# Type stubs are checked into the repo, don't overwrite them.
if path.suffix.lower() == ".pyi":
continue
if path.suffix.lower() not in library_suffixes and "_wrapper" not in path.name:
continue
dest_ = dest / path.name
print(f"Copying {path} -> {dest_}")
try:
Expand Down Expand Up @@ -174,17 +227,13 @@ def main() -> None:

for path, dest in symlinks:
print(f"Linking {path} -> {dest}.")
if path.is_dir():
if path.is_symlink():
path.unlink()
else:
shutil.rmtree(path)
# Check `is_symlink` in case if it's a broken symlink.
elif path.is_file() or path.is_symlink():
path.unlink()
else:
pass
path.symlink_to(dest, dest.is_dir())
try:
remove_path_with_retries(path)
path.symlink_to(dest, dest.is_dir())
except OSError:
# Removal and symlinking can't be atomic, so say what state we're leaving behind.
print(PARTIAL_LINK_ERROR.format(path=path))
raise

print("Download third party dependencies...")
BONSAI_DATA = PACKAGE_PATH / "bonsai" / "bim" / "data"
Expand Down