Skip to content

Commit bc87b3e

Browse files
Add helper to install BlenderGIS dependencies
1 parent 1b8189b commit bc87b3e

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Install the Python dependencies required by the BlenderGIS add-on.
2+
3+
This helper focuses on the packages that BlenderGIS relies on when it runs
4+
inside Blender's bundled Python interpreter. It can be executed with the
5+
current interpreter or pointed at Blender's python binary via ``--python``.
6+
7+
Example usage::
8+
9+
python scripts/install_blendergis_dependencies.py --python /path/to/blender/python
10+
11+
The script keeps the command output verbose so that users can audit the
12+
installation steps and diagnose issues (for example when GDAL requires
13+
additional system libraries).
14+
"""
15+
from __future__ import annotations
16+
17+
import argparse
18+
import os
19+
import shlex
20+
import shutil
21+
import subprocess
22+
import sys
23+
from dataclasses import dataclass
24+
from typing import Dict, Iterable, List, Sequence
25+
26+
27+
@dataclass(frozen=True)
28+
class Package:
29+
"""Descriptor for a Python package that BlenderGIS depends on."""
30+
31+
name: str
32+
reason: str
33+
optional: bool = False
34+
35+
36+
CORE_PACKAGES: Sequence[Package] = (
37+
Package(
38+
name="numpy",
39+
reason="Required for raster manipulation and array math used throughout BlenderGIS.",
40+
),
41+
Package(
42+
name="Pillow",
43+
reason="Provides image format handling leveraged by the Tyf module and raster tools.",
44+
),
45+
Package(
46+
name="pyproj",
47+
reason="Used for coordinate reference system transformations when GDAL is unavailable.",
48+
),
49+
Package(
50+
name="imageio",
51+
reason="Supplies the FreeImage plugin that BlenderGIS calls for reading imagery.",
52+
),
53+
)
54+
55+
OPTIONAL_PACKAGES: Sequence[Package] = (
56+
Package(
57+
name="gdal",
58+
reason=(
59+
"Offers high-performance access to raster and projection utilities.\n"
60+
"GDAL wheels are platform specific and may require OS-level libraries."
61+
),
62+
optional=True,
63+
),
64+
)
65+
66+
67+
def _resolve_python_executable(candidate: str) -> str:
68+
"""Return the absolute path to the python interpreter to use."""
69+
70+
if os.path.isabs(candidate) and os.path.exists(candidate):
71+
return candidate
72+
73+
resolved = shutil.which(candidate)
74+
if resolved:
75+
return resolved
76+
77+
raise FileNotFoundError(f"Unable to locate python interpreter: {candidate!r}")
78+
79+
80+
def _prepare_pip(python: str, *, upgrade: bool = True) -> None:
81+
"""Ensure that ``pip`` is available for the provided interpreter."""
82+
83+
try:
84+
_run_command([python, "-m", "pip", "--version"], dry_run=False)
85+
except subprocess.CalledProcessError:
86+
# pip is missing, try to bootstrap it via ensurepip
87+
_run_command([python, "-m", "ensurepip", "--upgrade"], dry_run=False)
88+
else:
89+
if not upgrade:
90+
return
91+
92+
if upgrade:
93+
_run_command([python, "-m", "pip", "install", "--upgrade", "pip"], dry_run=False)
94+
95+
96+
def _run_command(command: Sequence[str], *, dry_run: bool) -> subprocess.CompletedProcess[str] | None:
97+
"""Execute ``command`` while echoing it to stdout.
98+
99+
When ``dry_run`` is true the command is only printed.
100+
"""
101+
102+
print(f"$ {shlex.join(command)}", flush=True)
103+
if dry_run:
104+
return None
105+
return subprocess.run(command, check=True, text=True)
106+
107+
108+
def _install_packages(
109+
python: str,
110+
packages: Iterable[Package],
111+
*,
112+
dry_run: bool,
113+
pip_args: Sequence[str],
114+
allow_failure: bool = False,
115+
) -> Dict[str, bool]:
116+
"""Install each package and report success or failure."""
117+
118+
results: Dict[str, bool] = {}
119+
for package in packages:
120+
print(f"\nInstalling {package.name}{package.reason}")
121+
try:
122+
_run_command([python, "-m", "pip", "install", package.name, *pip_args], dry_run=dry_run)
123+
except subprocess.CalledProcessError:
124+
results[package.name] = False
125+
if allow_failure:
126+
print(
127+
f"Warning: installing {package.name} failed.\n"
128+
"Some BlenderGIS features may be unavailable."
129+
)
130+
else:
131+
raise
132+
else:
133+
results[package.name] = True
134+
return results
135+
136+
137+
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
138+
parser = argparse.ArgumentParser(
139+
description="Install the Python dependencies that BlenderGIS expects.",
140+
formatter_class=argparse.RawDescriptionHelpFormatter,
141+
epilog=(
142+
"The script installs the core runtime requirements by default. "
143+
"Use --include-optional to also attempt installing GDAL.\n"
144+
"Additional packages may be passed with --extra-package."
145+
),
146+
)
147+
parser.add_argument(
148+
"--python",
149+
default=sys.executable,
150+
help="Python interpreter to run pip with (defaults to the current interpreter).",
151+
)
152+
parser.add_argument(
153+
"--skip-pip-upgrade",
154+
action="store_true",
155+
help="Do not upgrade pip before installing packages.",
156+
)
157+
parser.add_argument(
158+
"--dry-run",
159+
action="store_true",
160+
help="Print the commands without executing them.",
161+
)
162+
parser.add_argument(
163+
"--include-optional",
164+
action="store_true",
165+
help="Attempt to install optional dependencies such as GDAL.",
166+
)
167+
parser.add_argument(
168+
"--skip-package",
169+
action="append",
170+
default=[],
171+
metavar="NAME",
172+
help="Name of a package to skip (may be repeated).",
173+
)
174+
parser.add_argument(
175+
"--extra-package",
176+
action="append",
177+
default=[],
178+
metavar="SPEC",
179+
help="Additional package specifiers to install in addition to the defaults.",
180+
)
181+
parser.add_argument(
182+
"--pip-extra-args",
183+
default="",
184+
metavar="ARGS",
185+
help="Extra arguments forwarded to 'pip install' (e.g. \"--pre --find-links ...\").",
186+
)
187+
parser.add_argument(
188+
"--list-only",
189+
action="store_true",
190+
help="Show the packages that would be installed and exit.",
191+
)
192+
return parser.parse_args(argv)
193+
194+
195+
def main(argv: Sequence[str] | None = None) -> int:
196+
args = parse_args(sys.argv[1:] if argv is None else argv)
197+
198+
try:
199+
python = _resolve_python_executable(args.python)
200+
except FileNotFoundError as exc:
201+
print(exc, file=sys.stderr)
202+
return 2
203+
204+
pip_args = shlex.split(args.pip_extra_args)
205+
206+
selected_packages: List[Package] = []
207+
skipped = {name.lower() for name in args.skip_package}
208+
209+
for package in CORE_PACKAGES:
210+
if package.name.lower() not in skipped:
211+
selected_packages.append(package)
212+
213+
optional_packages = OPTIONAL_PACKAGES if args.include_optional else ()
214+
for package in optional_packages:
215+
if package.name.lower() not in skipped:
216+
selected_packages.append(package)
217+
218+
if args.extra_package:
219+
selected_packages.extend(
220+
Package(name=spec, reason="User supplied dependency.", optional=False)
221+
for spec in args.extra_package
222+
if spec.lower() not in skipped
223+
)
224+
225+
if args.list_only:
226+
if selected_packages:
227+
print("Packages queued for installation:")
228+
for package in selected_packages:
229+
suffix = " (optional)" if package.optional else ""
230+
print(f" - {package.name}{suffix}: {package.reason}")
231+
else:
232+
print("No packages selected for installation.")
233+
return 0
234+
235+
if not args.dry_run:
236+
_prepare_pip(python, upgrade=not args.skip_pip_upgrade)
237+
238+
core_failures = False
239+
if selected_packages:
240+
results = _install_packages(
241+
python,
242+
selected_packages,
243+
dry_run=args.dry_run,
244+
pip_args=pip_args,
245+
allow_failure=True,
246+
)
247+
core_failures = any(
248+
not success and not package.optional
249+
for package in selected_packages
250+
for success in (results[package.name],)
251+
)
252+
else:
253+
print("No packages selected. Nothing to do.")
254+
255+
if core_failures:
256+
print("\nOne or more core dependencies failed to install.", file=sys.stderr)
257+
return 1
258+
259+
print("\nDependency installation completed.")
260+
return 0
261+
262+
263+
if __name__ == "__main__": # pragma: no cover
264+
sys.exit(main())

0 commit comments

Comments
 (0)