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 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,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))