beb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
beb/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Public package exports for Blender Extension Builder.
2
+
3
+ Copyright 2026 Alex Telford, Minimal Effort Tech.
4
+ """
5
+
6
+ from ._version import __version__
7
+ from .api import (
8
+ build_deploy_bundle,
9
+ build_docs,
10
+ build_extension,
11
+ cli_build,
12
+ cli_deploy,
13
+ cli_install,
14
+ cli_main,
15
+ discover_blender_installs,
16
+ load_manifest,
17
+ )
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "build_deploy_bundle",
22
+ "build_docs",
23
+ "build_extension",
24
+ "cli_build",
25
+ "cli_deploy",
26
+ "cli_install",
27
+ "cli_main",
28
+ "discover_blender_installs",
29
+ "load_manifest",
30
+ ]
@@ -0,0 +1,6 @@
1
+ """Internal implementation modules for Blender Extension Builder.
2
+
3
+ Copyright 2026 Alex Telford, Minimal Effort Tech.
4
+ """
5
+
6
+ __all__ = []
@@ -0,0 +1,192 @@
1
+ """Blender discovery and install helpers.
2
+
3
+ Copyright 2026 Alex Telford, Minimal Effort Tech.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import platform
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from .errors import BuildError
17
+
18
+
19
+ COMMON_BLENDER_ARGS = ["--factory-startup"]
20
+ WINDOWS_PATTERN = re.compile(r"^Blender (?P<version>\d+(?:\.\d+)*)$")
21
+ MACOS_PATTERN = re.compile(r"^Blender(?: (?P<version>\d+(?:\.\d+)*))?\.app$")
22
+ LINUX_PATTERN = re.compile(r"blender[- ]?(?P<version>\d+(?:\.\d+)*)", re.IGNORECASE)
23
+ VERSION_PATTERN = re.compile(r"\d+(?:\.\d+)*")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class BlenderInstall:
28
+ """Describe one discovered Blender executable."""
29
+
30
+ executable_path: Path
31
+ version: str | None
32
+
33
+ @property
34
+ def label(self) -> str:
35
+ """Return a printable label for the install."""
36
+
37
+ if self.version:
38
+ return f"{self.version} ({self.executable_path})"
39
+ return str(self.executable_path)
40
+
41
+
42
+ def discover_blender_installs() -> list[BlenderInstall]:
43
+ """Discover installed Blender executables on the current platform."""
44
+
45
+ system_name = platform.system()
46
+ if system_name == "Windows":
47
+ installs = _discover_windows_installs()
48
+ elif system_name == "Darwin":
49
+ installs = _discover_macos_installs()
50
+ else:
51
+ installs = _discover_linux_installs()
52
+
53
+ blender_on_path = shutil.which("blender")
54
+ if blender_on_path:
55
+ path_install = BlenderInstall(Path(blender_on_path), _extract_version(str(blender_on_path)))
56
+ installs.append(path_install)
57
+
58
+ deduped: dict[Path, BlenderInstall] = {}
59
+ for install in installs:
60
+ deduped[install.executable_path.resolve()] = install
61
+ return sorted(deduped.values(), key=_install_sort_key, reverse=True)
62
+
63
+
64
+ def select_blender_install(
65
+ version_request: str | None = None, installs: list[BlenderInstall] | None = None
66
+ ) -> BlenderInstall:
67
+ """Select the newest matching Blender install for an optional version prefix."""
68
+
69
+ installs = sorted(installs or discover_blender_installs(), key=_install_sort_key, reverse=True)
70
+ if not installs:
71
+ raise BuildError("Unable to find a Blender installation.")
72
+
73
+ if not version_request:
74
+ return installs[0]
75
+
76
+ request_parts = _version_parts(version_request)
77
+ matching = [
78
+ install for install in installs if install.version and _version_matches_prefix(install.version, request_parts)
79
+ ]
80
+ if matching:
81
+ return matching[0]
82
+
83
+ raise BuildError(f"Unable to find a Blender installation matching version '{version_request}'.")
84
+
85
+
86
+ def install_extension_archive(
87
+ archive_path: Path,
88
+ *,
89
+ version_request: str | None = None,
90
+ blender_executable: Path | None = None,
91
+ ) -> BlenderInstall:
92
+ """Install an extension archive into a selected Blender installation."""
93
+
94
+ install = _resolve_install(version_request=version_request, blender_executable=blender_executable)
95
+ command = [
96
+ str(install.executable_path),
97
+ *COMMON_BLENDER_ARGS,
98
+ "--command",
99
+ "extension",
100
+ "install-file",
101
+ "--repo=user_default",
102
+ "--enable",
103
+ str(archive_path),
104
+ ]
105
+ result = subprocess.run(command)
106
+ if result.returncode != 0:
107
+ raise BuildError(f"Blender install failed with exit code {result.returncode}.")
108
+ return install
109
+
110
+
111
+ def format_blender_installs(installs: list[BlenderInstall]) -> list[str]:
112
+ """Return printable descriptions of discovered installs."""
113
+
114
+ return [install.label for install in installs]
115
+
116
+
117
+ def _resolve_install(version_request: str | None, blender_executable: Path | None) -> BlenderInstall:
118
+ if blender_executable is not None:
119
+ executable_path = blender_executable.expanduser().resolve()
120
+ if not executable_path.exists():
121
+ raise BuildError(f"Blender executable was not found: {executable_path}")
122
+ return BlenderInstall(executable_path, _extract_version(str(executable_path)))
123
+ return select_blender_install(version_request)
124
+
125
+
126
+ def _discover_windows_installs() -> list[BlenderInstall]:
127
+ base_dir = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Blender Foundation"
128
+ if not base_dir.exists():
129
+ return []
130
+ installs: list[BlenderInstall] = []
131
+ for entry in base_dir.iterdir():
132
+ if not entry.is_dir():
133
+ continue
134
+ match = WINDOWS_PATTERN.match(entry.name)
135
+ if not match:
136
+ continue
137
+ executable_path = entry / "blender.exe"
138
+ if executable_path.exists():
139
+ installs.append(BlenderInstall(executable_path, match.group("version")))
140
+ return installs
141
+
142
+
143
+ def _discover_macos_installs() -> list[BlenderInstall]:
144
+ applications_dir = Path("/Applications")
145
+ if not applications_dir.exists():
146
+ return []
147
+ installs: list[BlenderInstall] = []
148
+ for app_path in applications_dir.glob("Blender*.app"):
149
+ match = MACOS_PATTERN.match(app_path.name)
150
+ executable_path = app_path / "Contents" / "MacOS" / "Blender"
151
+ if executable_path.exists():
152
+ installs.append(BlenderInstall(executable_path, match.group("version") if match else None))
153
+ return installs
154
+
155
+
156
+ def _discover_linux_installs() -> list[BlenderInstall]:
157
+ installs: list[BlenderInstall] = []
158
+ for candidate in [Path("/usr/bin/blender"), Path("/usr/local/bin/blender"), Path("/snap/bin/blender")]:
159
+ if candidate.exists():
160
+ installs.append(BlenderInstall(candidate, _extract_version(str(candidate))))
161
+ for pattern in ("/opt/blender*/blender", "/usr/local/blender*/blender"):
162
+ for candidate in Path("/").glob(pattern.lstrip("/")):
163
+ if candidate.exists():
164
+ installs.append(BlenderInstall(candidate, _extract_version(str(candidate.parent))))
165
+ return installs
166
+
167
+
168
+ def _extract_version(text: str) -> str | None:
169
+ match = VERSION_PATTERN.search(text)
170
+ if match:
171
+ return match.group(0)
172
+ linux_match = LINUX_PATTERN.search(text)
173
+ if linux_match:
174
+ return linux_match.group("version")
175
+ return None
176
+
177
+
178
+ def _version_parts(version_text: str | None) -> tuple[int, ...]:
179
+ if not version_text:
180
+ return ()
181
+ return tuple(int(part) for part in version_text.split("."))
182
+
183
+
184
+ def _version_matches_prefix(version_text: str, request_parts: tuple[int, ...]) -> bool:
185
+ version_parts = _version_parts(version_text)
186
+ if not request_parts or len(version_parts) < len(request_parts):
187
+ return False
188
+ return version_parts[: len(request_parts)] == request_parts
189
+
190
+
191
+ def _install_sort_key(install: BlenderInstall) -> tuple[tuple[int, ...], str]:
192
+ return (_version_parts(install.version), str(install.executable_path))
@@ -0,0 +1,220 @@
1
+ """Archive and deploy bundle generation.
2
+
3
+ Copyright 2026 Alex Telford, Minimal Effort Tech.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import tempfile
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from ..constants import DEFAULT_BUILD_DIRNAME, DEFAULT_DEPLOY_DIRNAME, DEFAULT_WHEELS_DIRNAME
13
+ from ..structs import BuildResult, DeployVariant, Manifest
14
+
15
+ from .blender import install_extension_archive
16
+ from .docs import build_docs
17
+ from .errors import BuildError
18
+ from .manifest import load_manifest
19
+ from .utils import copy_file, ensure_dir, iter_project_files, matches_any
20
+ from .wheels import resolve_manifest_wheels
21
+
22
+
23
+ def build_extension(
24
+ manifest_path: Path,
25
+ *,
26
+ variant: str | None = None,
27
+ platforms: tuple[str, ...] | list[str] | None = None,
28
+ output_dir: Path | None = None,
29
+ install_blender: bool = False,
30
+ blender_version: str | None = None,
31
+ blender_executable: Path | None = None,
32
+ ) -> BuildResult:
33
+ """Build one Blender extension archive and optionally install it into Blender."""
34
+
35
+ manifest = load_manifest(manifest_path)
36
+ project_root = manifest.source_path.parent
37
+ build_dir = ensure_dir(output_dir or project_root / DEFAULT_BUILD_DIRNAME)
38
+ deploy_variant = _select_variant(manifest, variant)
39
+ variant_platforms = deploy_variant.platforms if deploy_variant else ()
40
+ selected_platforms = tuple(platforms or variant_platforms or manifest.platforms)
41
+ include_wheels = deploy_variant.wheels if deploy_variant else True
42
+
43
+ wheel_paths = []
44
+ if manifest.embedded_wheels or (include_wheels and manifest.requires):
45
+ wheel_paths = resolve_manifest_wheels(
46
+ manifest,
47
+ project_root,
48
+ selected_platforms=selected_platforms,
49
+ include_requirements=include_wheels,
50
+ )
51
+
52
+ archive_name = _archive_name(manifest, deploy_variant)
53
+ archive_path = build_dir / archive_name
54
+
55
+ with tempfile.TemporaryDirectory(prefix="beb-") as temp_dir:
56
+ staging_dir = Path(temp_dir) / manifest.id
57
+ staging_dir.mkdir(parents=True, exist_ok=True)
58
+ _copy_manifest_files(manifest, project_root, staging_dir)
59
+ _write_blender_manifest(manifest, staging_dir, selected_platforms, wheel_paths)
60
+ _copy_wheels(wheel_paths, staging_dir)
61
+ _write_archive(staging_dir, archive_path)
62
+
63
+ installed_blender_path = None
64
+ if install_blender:
65
+ install = install_extension_archive(
66
+ archive_path,
67
+ version_request=blender_version,
68
+ blender_executable=blender_executable,
69
+ )
70
+ installed_blender_path = install.executable_path
71
+
72
+ return BuildResult(
73
+ manifest=manifest,
74
+ archive_paths=(archive_path,),
75
+ installed_blender_path=installed_blender_path,
76
+ )
77
+
78
+
79
+ def build_deploy_bundle(manifest_path: Path, *, output_dir: Path | None = None) -> BuildResult:
80
+ """Build a deploy bundle containing extension archives and optional docs."""
81
+
82
+ manifest = load_manifest(manifest_path)
83
+ project_root = manifest.source_path.parent
84
+ bundle_dir = ensure_dir(output_dir or project_root / DEFAULT_DEPLOY_DIRNAME)
85
+ extensions_dir = ensure_dir(bundle_dir / "extensions")
86
+
87
+ archive_paths: list[Path] = []
88
+ if manifest.deploy_variants:
89
+ for variant in manifest.deploy_variants:
90
+ result = build_extension(manifest.source_path, variant=variant.suffix, output_dir=extensions_dir)
91
+ archive_paths.extend(result.archive_paths)
92
+ else:
93
+ result = build_extension(manifest.source_path, output_dir=extensions_dir)
94
+ archive_paths.extend(result.archive_paths)
95
+
96
+ docs_output_dir = None
97
+ if manifest.docs.enabled:
98
+ docs_output_dir = build_docs(manifest.source_path, output_dir=bundle_dir / "documentation")
99
+
100
+ for extra_name in ("LICENSE", "README.md", "THIRD_PARTY_NOTICES"):
101
+ source = project_root / extra_name
102
+ if source.exists():
103
+ copy_file(source, bundle_dir / source.name)
104
+
105
+ if manifest.deploy_instructions:
106
+ (bundle_dir / "INSTALL.txt").write_text(manifest.deploy_instructions + "\n", encoding="utf-8")
107
+
108
+ return BuildResult(
109
+ manifest=manifest,
110
+ archive_paths=tuple(archive_paths),
111
+ docs_output_dir=docs_output_dir,
112
+ deploy_bundle_dir=bundle_dir,
113
+ )
114
+
115
+
116
+ def _select_variant(manifest: Manifest, suffix: str | None) -> DeployVariant | None:
117
+ """Return the selected deploy variant when a suffix is provided."""
118
+
119
+ if suffix is None:
120
+ return None
121
+ for variant in manifest.deploy_variants:
122
+ if variant.suffix == suffix:
123
+ return variant
124
+ raise BuildError(f"Unknown deploy variant '{suffix}'.")
125
+
126
+
127
+ def _archive_name(manifest: Manifest, variant: DeployVariant | None) -> str:
128
+ """Build the output archive name for one manifest and variant."""
129
+
130
+ suffix = f"-{variant.suffix}" if variant and variant.suffix else ""
131
+ return f"{manifest.id}-{manifest.version}{suffix}.zip"
132
+
133
+
134
+ def _copy_manifest_files(manifest: Manifest, project_root: Path, staging_dir: Path) -> None:
135
+ """Copy manifest-selected project files into the staging directory."""
136
+
137
+ included_any = False
138
+ for file_path in iter_project_files(project_root):
139
+ relative_path = file_path.relative_to(project_root).as_posix()
140
+ if matches_any(relative_path, manifest.paths_exclude):
141
+ continue
142
+ if not matches_any(relative_path, manifest.paths_include):
143
+ continue
144
+ included_any = True
145
+ copy_file(file_path, staging_dir / relative_path)
146
+ if not included_any:
147
+ raise BuildError("Manifest include patterns did not match any files.")
148
+
149
+
150
+ def _copy_wheels(wheel_paths: list[Path], staging_dir: Path) -> None:
151
+ """Copy resolved wheel files into the staged package."""
152
+
153
+ if not wheel_paths:
154
+ return
155
+ wheels_dir = staging_dir / DEFAULT_WHEELS_DIRNAME
156
+ wheels_dir.mkdir(parents=True, exist_ok=True)
157
+ for wheel_path in wheel_paths:
158
+ copy_file(wheel_path, wheels_dir / wheel_path.name)
159
+
160
+
161
+ def _write_blender_manifest(
162
+ manifest: Manifest,
163
+ staging_dir: Path,
164
+ selected_platforms: tuple[str, ...],
165
+ wheel_paths: list[Path],
166
+ ) -> None:
167
+ """Write the generated Blender TOML manifest into the staging directory."""
168
+
169
+ content = render_blender_manifest(manifest, selected_platforms, wheel_paths)
170
+ (staging_dir / "blender_manifest.toml").write_text(content, encoding="utf-8")
171
+
172
+
173
+ def render_blender_manifest(manifest: Manifest, selected_platforms: tuple[str, ...], wheel_paths: list[Path]) -> str:
174
+ """Render the Blender extension manifest TOML from normalized manifest data."""
175
+
176
+ lines = [
177
+ f'schema_version = "{_toml_escape(manifest.schema_version)}"',
178
+ f'id = "{_toml_escape(manifest.id)}"',
179
+ f'version = "{_toml_escape(manifest.version)}"',
180
+ f'name = "{_toml_escape(manifest.name)}"',
181
+ f'tagline = "{_toml_escape(manifest.tagline)}"',
182
+ f'maintainer = "{_toml_escape(manifest.maintainer)}"',
183
+ f'type = "{_toml_escape(manifest.type)}"',
184
+ f"tags = [{_toml_list(manifest.tags)}]",
185
+ f'blender_version_min = "{_toml_escape(manifest.blender_version_min)}"',
186
+ f"license = [{_toml_list(manifest.license)}]",
187
+ ]
188
+ if manifest.website:
189
+ lines.append(f'website = "{_toml_escape(manifest.website)}"')
190
+ if manifest.blender_version_max:
191
+ lines.append(f'blender_version_max = "{_toml_escape(manifest.blender_version_max)}"')
192
+ if selected_platforms:
193
+ lines.append(f"platforms = [{_toml_list(selected_platforms)}]")
194
+ if wheel_paths:
195
+ lines.append(
196
+ f"wheels = [{_toml_list(tuple(f'./{DEFAULT_WHEELS_DIRNAME}/{wheel_path.name}' for wheel_path in wheel_paths))}]"
197
+ )
198
+ return "\n".join(lines) + "\n"
199
+
200
+
201
+ def _toml_escape(value: str) -> str:
202
+ """Escape a string for TOML output."""
203
+
204
+ return value.replace("\\", "\\\\").replace('"', '\\"')
205
+
206
+
207
+ def _toml_list(values: tuple[str, ...]) -> str:
208
+ """Render a string tuple as a TOML string array."""
209
+
210
+ return ", ".join(f'"{_toml_escape(value)}"' for value in values)
211
+
212
+
213
+ def _write_archive(staging_dir: Path, archive_path: Path) -> None:
214
+ """Write a zip archive from the prepared staging directory."""
215
+
216
+ archive_path.parent.mkdir(parents=True, exist_ok=True)
217
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
218
+ for file_path in staging_dir.rglob("*"):
219
+ if file_path.is_file():
220
+ archive.write(file_path, file_path.relative_to(staging_dir))