beb 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- beb-0.1.0/PKG-INFO +87 -0
- beb-0.1.0/README.md +57 -0
- beb-0.1.0/beb/__init__.py +30 -0
- beb-0.1.0/beb/_internal/__init__.py +6 -0
- beb-0.1.0/beb/_internal/blender.py +192 -0
- beb-0.1.0/beb/_internal/builder.py +220 -0
- beb-0.1.0/beb/_internal/cli.py +276 -0
- beb-0.1.0/beb/_internal/docs.py +276 -0
- beb-0.1.0/beb/_internal/errors.py +20 -0
- beb-0.1.0/beb/_internal/manifest.py +238 -0
- beb-0.1.0/beb/_internal/static/docs.css +113 -0
- beb-0.1.0/beb/_internal/utils.py +76 -0
- beb-0.1.0/beb/_internal/wheels.py +221 -0
- beb-0.1.0/beb/_version.py +6 -0
- beb-0.1.0/beb/api.py +65 -0
- beb-0.1.0/beb/constants.py +18 -0
- beb-0.1.0/beb/structs.py +107 -0
- beb-0.1.0/beb.egg-info/PKG-INFO +87 -0
- beb-0.1.0/beb.egg-info/SOURCES.txt +30 -0
- beb-0.1.0/beb.egg-info/dependency_links.txt +1 -0
- beb-0.1.0/beb.egg-info/entry_points.txt +5 -0
- beb-0.1.0/beb.egg-info/requires.txt +2 -0
- beb-0.1.0/beb.egg-info/top_level.txt +1 -0
- beb-0.1.0/pyproject.toml +5 -0
- beb-0.1.0/setup.cfg +4 -0
- beb-0.1.0/setup.py +67 -0
- beb-0.1.0/tests/test_blender.py +45 -0
- beb-0.1.0/tests/test_builder.py +108 -0
- beb-0.1.0/tests/test_docs.py +49 -0
- beb-0.1.0/tests/test_manifest.py +42 -0
- beb-0.1.0/tests/test_wheels.py +44 -0
beb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Alex Telford, Minimal Effort Tech.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
beb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: beb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build Blender extension archives from JSON or YAML manifests
|
|
5
|
+
Home-page: https://github.com/minimalefforttech/beb
|
|
6
|
+
Author: Alex Telford
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Repository, https://github.com/minimalefforttech/beb
|
|
9
|
+
Project-URL: Issues, https://github.com/minimalefforttech/beb/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: Markdown>=3.6
|
|
18
|
+
Requires-Dist: PyYAML>=6.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: home-page
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
Dynamic: project-url
|
|
27
|
+
Dynamic: requires-dist
|
|
28
|
+
Dynamic: requires-python
|
|
29
|
+
Dynamic: summary
|
|
30
|
+
|
|
31
|
+
<!-- Copyright 2026 Alex Telford, Minimal Effort Tech. -->
|
|
32
|
+
|
|
33
|
+
# Blender Extension Builder
|
|
34
|
+
|
|
35
|
+
Reusable tools for building Blender extension packages from a project manifest.
|
|
36
|
+
|
|
37
|
+
Source repository: https://github.com/minimalefforttech/beb
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Build Blender extension zip archives from `manifest.json`
|
|
42
|
+
- Load `manifest.yaml` and `manifest.yml` as alternate formats when `PyYAML` is available
|
|
43
|
+
- Download and cache dependency wheels per target platform
|
|
44
|
+
- Embed explicit custom wheels without forcing the wheel version to match the manifest version
|
|
45
|
+
- Generate configurable HTML docs from Markdown when `Markdown` is available
|
|
46
|
+
- Create deploy bundles with extension archives and generated documentation
|
|
47
|
+
- Use the package from Python or through the `beb` CLI
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install beb
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`PyYAML` and `Markdown` are included as standard dependencies, but the package degrades gracefully if either feature dependency is unavailable in a target environment.
|
|
56
|
+
|
|
57
|
+
The published package name is `beb` and the top-level Python import package is also `beb`.
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
beb validate
|
|
63
|
+
beb build
|
|
64
|
+
beb install --install-dir ./build/install
|
|
65
|
+
beb build --deploy
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Project Layout
|
|
69
|
+
|
|
70
|
+
- `beb`: importable package
|
|
71
|
+
- `cli`: small repository helper commands
|
|
72
|
+
- `examples`: example manifests for common workflows
|
|
73
|
+
- `docs`: user and developer guides
|
|
74
|
+
- `tests`: automated coverage for the manifest, wheels, builder, and docs pipeline
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
- User guide: `docs/user_guide.md`
|
|
79
|
+
- Developer guide: `docs/developer_guide.md`
|
|
80
|
+
|
|
81
|
+
## Releases
|
|
82
|
+
|
|
83
|
+
GitHub Actions builds wheels and source distributions on every push and publishes tagged releases to PyPI.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
This project is licensed under the MIT License. See `LICENSE`.
|
beb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!-- Copyright 2026 Alex Telford, Minimal Effort Tech. -->
|
|
2
|
+
|
|
3
|
+
# Blender Extension Builder
|
|
4
|
+
|
|
5
|
+
Reusable tools for building Blender extension packages from a project manifest.
|
|
6
|
+
|
|
7
|
+
Source repository: https://github.com/minimalefforttech/beb
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Build Blender extension zip archives from `manifest.json`
|
|
12
|
+
- Load `manifest.yaml` and `manifest.yml` as alternate formats when `PyYAML` is available
|
|
13
|
+
- Download and cache dependency wheels per target platform
|
|
14
|
+
- Embed explicit custom wheels without forcing the wheel version to match the manifest version
|
|
15
|
+
- Generate configurable HTML docs from Markdown when `Markdown` is available
|
|
16
|
+
- Create deploy bundles with extension archives and generated documentation
|
|
17
|
+
- Use the package from Python or through the `beb` CLI
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install beb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`PyYAML` and `Markdown` are included as standard dependencies, but the package degrades gracefully if either feature dependency is unavailable in a target environment.
|
|
26
|
+
|
|
27
|
+
The published package name is `beb` and the top-level Python import package is also `beb`.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
beb validate
|
|
33
|
+
beb build
|
|
34
|
+
beb install --install-dir ./build/install
|
|
35
|
+
beb build --deploy
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Project Layout
|
|
39
|
+
|
|
40
|
+
- `beb`: importable package
|
|
41
|
+
- `cli`: small repository helper commands
|
|
42
|
+
- `examples`: example manifests for common workflows
|
|
43
|
+
- `docs`: user and developer guides
|
|
44
|
+
- `tests`: automated coverage for the manifest, wheels, builder, and docs pipeline
|
|
45
|
+
|
|
46
|
+
## Documentation
|
|
47
|
+
|
|
48
|
+
- User guide: `docs/user_guide.md`
|
|
49
|
+
- Developer guide: `docs/developer_guide.md`
|
|
50
|
+
|
|
51
|
+
## Releases
|
|
52
|
+
|
|
53
|
+
GitHub Actions builds wheels and source distributions on every push and publishes tagged releases to PyPI.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
This project is licensed under the MIT License. See `LICENSE`.
|
|
@@ -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,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))
|