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 +30 -0
- beb/_internal/__init__.py +6 -0
- beb/_internal/blender.py +192 -0
- beb/_internal/builder.py +220 -0
- beb/_internal/cli.py +276 -0
- beb/_internal/docs.py +276 -0
- beb/_internal/errors.py +20 -0
- beb/_internal/manifest.py +238 -0
- beb/_internal/static/docs.css +113 -0
- beb/_internal/utils.py +76 -0
- beb/_internal/wheels.py +221 -0
- beb/_version.py +6 -0
- beb/api.py +65 -0
- beb/constants.py +18 -0
- beb/structs.py +107 -0
- beb-0.1.0.dist-info/METADATA +87 -0
- beb-0.1.0.dist-info/RECORD +21 -0
- beb-0.1.0.dist-info/WHEEL +5 -0
- beb-0.1.0.dist-info/entry_points.txt +5 -0
- beb-0.1.0.dist-info/licenses/LICENSE +21 -0
- beb-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
beb/_internal/blender.py
ADDED
|
@@ -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))
|
beb/_internal/builder.py
ADDED
|
@@ -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))
|