pybend 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.
- pybend/__init__.py +3 -0
- pybend/cli.py +642 -0
- pybend/compiler.py +182 -0
- pybend/config.py +136 -0
- pybend/dependency_resolver.py +222 -0
- pybend/detector.py +266 -0
- pybend/importer.py +234 -0
- pybend/inspector.py +85 -0
- pybend/splicer.py +20 -0
- pybend/templates/linux/x86_64/python3.10/bootloader +0 -0
- pybend/templates/linux/x86_64/python3.11/bootloader +0 -0
- pybend/templates/linux/x86_64/python3.12/bootloader +0 -0
- pybend/templates/linux/x86_64/python3.13/bootloader +0 -0
- pybend/templates/linux/x86_64/python3.14/.gitkeep +0 -0
- pybend/templates/linux/x86_64/python3.14/bootloader +0 -0
- pybend/templates/windows/x86_64/python3.12/bootloader.exe +0 -0
- pybend/vfs_builder.py +110 -0
- pybend-0.1.0.dist-info/METADATA +274 -0
- pybend-0.1.0.dist-info/RECORD +22 -0
- pybend-0.1.0.dist-info/WHEEL +4 -0
- pybend-0.1.0.dist-info/entry_points.txt +2 -0
- pybend-0.1.0.dist-info/licenses/LICENSE +21 -0
pybend/__init__.py
ADDED
pybend/cli.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
from io import StringIO
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.theme import Theme
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich import box
|
|
13
|
+
|
|
14
|
+
from pybend.config import resolve_config
|
|
15
|
+
from pybend.compiler import compile_app
|
|
16
|
+
from pybend.detector import detect_django, generate_entry_code
|
|
17
|
+
|
|
18
|
+
custom_theme = Theme(
|
|
19
|
+
{
|
|
20
|
+
"info": "cyan",
|
|
21
|
+
"warning": "yellow",
|
|
22
|
+
"error": "bold red",
|
|
23
|
+
"success": "bold green",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_console(quiet: bool = False) -> Console:
|
|
29
|
+
if quiet:
|
|
30
|
+
return Console(file=StringIO(), theme=custom_theme)
|
|
31
|
+
return Console(theme=custom_theme)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _py_version() -> str:
|
|
35
|
+
return f"python{sys.version_info.major}.{sys.version_info.minor}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bootloader_name() -> str:
|
|
39
|
+
return "bootloader.exe" if sys.platform == "win32" else "bootloader"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _platform_path(templates_dir: Path, target: Optional[str] = None) -> Path:
|
|
43
|
+
import platform
|
|
44
|
+
if target:
|
|
45
|
+
parts = target.split("/", 1)
|
|
46
|
+
if len(parts) != 2:
|
|
47
|
+
raise click.UsageError(f"Invalid target format '{target}'. Use os/arch (e.g. linux/x86_64)")
|
|
48
|
+
return templates_dir / parts[0] / parts[1]
|
|
49
|
+
system = platform.system().lower()
|
|
50
|
+
machine = platform.machine().lower()
|
|
51
|
+
arch_map = {"x86_64": "x86_64", "aarch64": "aarch64", "amd64": "x86_64"}
|
|
52
|
+
os_map = {"linux": "linux", "darwin": "macos", "windows": "windows"}
|
|
53
|
+
return templates_dir / os_map.get(system, system) / arch_map.get(machine, machine)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _has_static_libpython() -> Optional[Path]:
|
|
57
|
+
"""Returns path to libpython.a if available and compiled with -fPIC."""
|
|
58
|
+
import sysconfig, subprocess
|
|
59
|
+
libdir = sysconfig.get_config_var("LIBDIR")
|
|
60
|
+
if not libdir:
|
|
61
|
+
return None
|
|
62
|
+
major = sys.version_info.major
|
|
63
|
+
minor = sys.version_info.minor
|
|
64
|
+
for name in [f"libpython{major}.{minor}.a", f"libpython{major}{minor}.a"]:
|
|
65
|
+
p = Path(libdir) / name
|
|
66
|
+
if p.exists():
|
|
67
|
+
return p
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_bootloader_from_source(target_dir: Path, quiet: bool = False, static: bool = False) -> Optional[Path]:
|
|
72
|
+
"""Attempts to compile the bootloader from Rust source using cargo."""
|
|
73
|
+
console = _get_console(quiet)
|
|
74
|
+
bootloader_dir = Path(__file__).resolve().parent.parent / "bootloader"
|
|
75
|
+
if not (bootloader_dir / "Cargo.toml").exists():
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if not _check_cargo():
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
py_ver = _py_version()
|
|
82
|
+
if not quiet:
|
|
83
|
+
console.print(f"[info]Building bootloader for {py_ver} from source...[/info]")
|
|
84
|
+
|
|
85
|
+
env = {**os.environ}
|
|
86
|
+
import sysconfig
|
|
87
|
+
stdlib = sysconfig.get_paths().get("stdlib", "")
|
|
88
|
+
platstdlib = sysconfig.get_paths().get("platstdlib", "")
|
|
89
|
+
if stdlib:
|
|
90
|
+
env["PYBEND_STDLIB_PATH"] = stdlib
|
|
91
|
+
if platstdlib:
|
|
92
|
+
env["PYBEND_PLATSTDLIB_PATH"] = platstdlib
|
|
93
|
+
|
|
94
|
+
libpython_a = _has_static_libpython()
|
|
95
|
+
is_static = static or bool(libpython_a)
|
|
96
|
+
|
|
97
|
+
lib_dir = bootloader_dir / "lib"
|
|
98
|
+
lib_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
ldlib = sysconfig.get_config_var("LDLIBRARY")
|
|
100
|
+
libdir = sysconfig.get_config_var("LIBDIR")
|
|
101
|
+
if libdir and ldlib:
|
|
102
|
+
src_lib = Path(libdir) / ldlib
|
|
103
|
+
dst_lib = lib_dir / ldlib
|
|
104
|
+
if src_lib.exists() and not dst_lib.exists():
|
|
105
|
+
dst_lib.symlink_to(src_lib)
|
|
106
|
+
env["RUSTFLAGS"] = f"-L {lib_dir}"
|
|
107
|
+
|
|
108
|
+
env["PYO3_PYTHON"] = sys.executable
|
|
109
|
+
env["PYTHON_SYS_EXECUTABLE"] = sys.executable
|
|
110
|
+
if is_static:
|
|
111
|
+
if libpython_a:
|
|
112
|
+
if not quiet:
|
|
113
|
+
console.print(f"[info]Static libpython found at {libpython_a}[/info]")
|
|
114
|
+
import tempfile
|
|
115
|
+
cfg = tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False, prefix="pyo3-")
|
|
116
|
+
cfg.write(f"""implementation = "CPython"
|
|
117
|
+
version = "{sys.version_info.major}.{sys.version_info.minor}"
|
|
118
|
+
shared = false
|
|
119
|
+
lib_name = "python{sys.version_info.major}.{sys.version_info.minor}"
|
|
120
|
+
lib_dir = "{libdir}"
|
|
121
|
+
executable = "{sys.executable}"
|
|
122
|
+
""")
|
|
123
|
+
cfg.close()
|
|
124
|
+
env["PYO3_CONFIG_FILE"] = cfg.name
|
|
125
|
+
if not quiet:
|
|
126
|
+
console.print(f"[info]Using Python: {sys.executable}[/info]")
|
|
127
|
+
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["cargo", "build", "--release"],
|
|
130
|
+
cwd=bootloader_dir,
|
|
131
|
+
env=env,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
if not quiet:
|
|
137
|
+
console.print(f"[warning]Cargo build stderr:[/warning] {result.stderr}")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
pybend_root = Path(__file__).resolve().parent.parent
|
|
141
|
+
for candidate in [
|
|
142
|
+
bootloader_dir / "target" / "release" / _bootloader_name(),
|
|
143
|
+
pybend_root / "target" / "release" / _bootloader_name(),
|
|
144
|
+
]:
|
|
145
|
+
if candidate.exists():
|
|
146
|
+
built = candidate
|
|
147
|
+
break
|
|
148
|
+
else:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Set rpath on the built bootloader so libpython can be found at runtime
|
|
152
|
+
import sysconfig
|
|
153
|
+
libdir = sysconfig.get_config_var("LIBDIR")
|
|
154
|
+
ldlib = sysconfig.get_config_var("LDLIBRARY")
|
|
155
|
+
if libdir and Path(libdir).exists():
|
|
156
|
+
_set_rpath(str(built), libdir)
|
|
157
|
+
|
|
158
|
+
# Stage into versioned template directory
|
|
159
|
+
versioned = target_dir / _py_version()
|
|
160
|
+
versioned.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
staged = versioned / _bootloader_name()
|
|
162
|
+
import shutil
|
|
163
|
+
shutil.copy2(built, staged)
|
|
164
|
+
os.chmod(staged, 0o755)
|
|
165
|
+
if not quiet:
|
|
166
|
+
console.print(f"[success]Bootloader built and staged at {staged}[/success]")
|
|
167
|
+
return staged
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _set_rpath(binary: str, *libdirs: str) -> None:
|
|
171
|
+
"""Set RUNPATH on an ELF binary so shared libraries are found at runtime."""
|
|
172
|
+
import subprocess
|
|
173
|
+
try:
|
|
174
|
+
rpath = ":".join(libdirs)
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["patchelf", "--set-rpath", rpath, binary],
|
|
177
|
+
capture_output=True,
|
|
178
|
+
timeout=10,
|
|
179
|
+
)
|
|
180
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_cargo() -> bool:
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
["cargo", "--version"], capture_output=True, text=True, timeout=10
|
|
188
|
+
)
|
|
189
|
+
return result.returncode == 0
|
|
190
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_libpython(bootloader_path: Path) -> bool:
|
|
195
|
+
"""Returns True if the bootloader's libpython dependency can be resolved."""
|
|
196
|
+
import platform as _platform
|
|
197
|
+
if _platform.system() != "Linux":
|
|
198
|
+
return True
|
|
199
|
+
try:
|
|
200
|
+
result = subprocess.run(
|
|
201
|
+
["ldd", str(bootloader_path)],
|
|
202
|
+
capture_output=True,
|
|
203
|
+
text=True,
|
|
204
|
+
timeout=10,
|
|
205
|
+
)
|
|
206
|
+
for line in result.stdout.splitlines():
|
|
207
|
+
if "libpython" in line and "not found" in line:
|
|
208
|
+
return False
|
|
209
|
+
return True
|
|
210
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def find_default_template(target: Optional[str] = None, python: Optional[str] = None, quiet: bool = False) -> Path:
|
|
215
|
+
"""Finds a bootloader template compatible with the given Python version."""
|
|
216
|
+
console = _get_console(quiet)
|
|
217
|
+
pybend_root = Path(__file__).resolve().parent.parent
|
|
218
|
+
py_ver = python if python else _py_version()
|
|
219
|
+
|
|
220
|
+
templates_dir = Path(__file__).resolve().parent / "templates"
|
|
221
|
+
base = _platform_path(templates_dir, target=target)
|
|
222
|
+
|
|
223
|
+
# 1. Packaged templates: versioned path first (these are CI-built or local-build staged)
|
|
224
|
+
versioned = base / py_ver / _bootloader_name()
|
|
225
|
+
if versioned.exists():
|
|
226
|
+
return versioned
|
|
227
|
+
|
|
228
|
+
# 2. Development builds from cargo target dir (fallback for dev)
|
|
229
|
+
for variant in ["release", "debug"]:
|
|
230
|
+
for p in [
|
|
231
|
+
pybend_root / "bootloader" / "target" / variant / _bootloader_name(),
|
|
232
|
+
pybend_root / "target" / variant / _bootloader_name(),
|
|
233
|
+
]:
|
|
234
|
+
if p.exists():
|
|
235
|
+
return p
|
|
236
|
+
|
|
237
|
+
unversioned = base / _bootloader_name()
|
|
238
|
+
if unversioned.exists():
|
|
239
|
+
if _check_libpython(unversioned):
|
|
240
|
+
return unversioned
|
|
241
|
+
console.print(
|
|
242
|
+
f"[warning]Packaged bootloader ({unversioned}) needs libpython for "
|
|
243
|
+
f"'{py_ver}' but it targets a different version.[/warning]"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# 3. Build from source if cargo is available
|
|
247
|
+
target_dir = _platform_path(templates_dir, target=target)
|
|
248
|
+
built = _build_bootloader_from_source(target_dir)
|
|
249
|
+
if built is not None:
|
|
250
|
+
return built
|
|
251
|
+
|
|
252
|
+
return templates_dir / "bootloader"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@click.group(invoke_without_command=True)
|
|
256
|
+
@click.pass_context
|
|
257
|
+
def main(ctx: click.Context) -> None:
|
|
258
|
+
"""PyBend — Compile Python applications into standalone native binaries.
|
|
259
|
+
|
|
260
|
+
Combines your application, its dependencies, and the Python runtime
|
|
261
|
+
into a single portable executable. Run without arguments to build
|
|
262
|
+
the project in the current directory.
|
|
263
|
+
|
|
264
|
+
Commands:
|
|
265
|
+
build Compile a Python app into a standalone binary.
|
|
266
|
+
bootstrap Build the native bootloader from Rust source.
|
|
267
|
+
inspect Display the contents of a compiled binary's VFS.
|
|
268
|
+
"""
|
|
269
|
+
if ctx.invoked_subcommand is None:
|
|
270
|
+
ctx.invoke(build)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@main.command()
|
|
274
|
+
@click.option(
|
|
275
|
+
"--entry", "-e",
|
|
276
|
+
type=str,
|
|
277
|
+
metavar="PATH",
|
|
278
|
+
help="Application entry point (e.g. app.py, manage.py, main.py). "
|
|
279
|
+
"Auto-detected in the current directory if not specified.",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--output", "-o",
|
|
283
|
+
type=str,
|
|
284
|
+
metavar="PATH",
|
|
285
|
+
help="Output binary path. Defaults to <project_dir>/dist/<entry_name>.",
|
|
286
|
+
)
|
|
287
|
+
@click.option(
|
|
288
|
+
"--optimize", "-O",
|
|
289
|
+
type=click.Choice(["0", "1", "2"]),
|
|
290
|
+
metavar="LEVEL",
|
|
291
|
+
help="Bytecode optimization level: 0=none, 1=remove assert/__debug__, "
|
|
292
|
+
"2=also remove docstrings (default: 2).",
|
|
293
|
+
)
|
|
294
|
+
@click.option(
|
|
295
|
+
"--include", "-i",
|
|
296
|
+
multiple=True,
|
|
297
|
+
metavar="MODULE",
|
|
298
|
+
help="Forcefully include a module in the bundle even if not traced "
|
|
299
|
+
"as a dependency. Repeatable.",
|
|
300
|
+
)
|
|
301
|
+
@click.option(
|
|
302
|
+
"--exclude", "-x",
|
|
303
|
+
multiple=True,
|
|
304
|
+
metavar="MODULE",
|
|
305
|
+
help="Exclude a module from the bundle. Overrides --include if both "
|
|
306
|
+
"specify the same module. Repeatable.",
|
|
307
|
+
)
|
|
308
|
+
@click.option(
|
|
309
|
+
"--template", "-t",
|
|
310
|
+
type=str,
|
|
311
|
+
metavar="PATH",
|
|
312
|
+
help="Custom bootloader binary path. Overrides automatic template "
|
|
313
|
+
"resolution. Use 'pybend bootstrap' to build one from source.",
|
|
314
|
+
)
|
|
315
|
+
@click.option(
|
|
316
|
+
"--config", "-c",
|
|
317
|
+
type=str,
|
|
318
|
+
metavar="PATH",
|
|
319
|
+
help="Path to pybend.toml configuration file. Defaults to "
|
|
320
|
+
"./pybend.toml if it exists.",
|
|
321
|
+
)
|
|
322
|
+
@click.option(
|
|
323
|
+
"--target", "-p",
|
|
324
|
+
type=str,
|
|
325
|
+
default=None,
|
|
326
|
+
metavar="OS/ARCH",
|
|
327
|
+
help="Target platform as os/arch (e.g. linux/x86_64, macos/aarch64). "
|
|
328
|
+
"Defaults to the current build platform.",
|
|
329
|
+
)
|
|
330
|
+
@click.option(
|
|
331
|
+
"--python", "-V",
|
|
332
|
+
type=str,
|
|
333
|
+
default=None,
|
|
334
|
+
metavar="VERSION",
|
|
335
|
+
help="Python version to target (e.g. 3.12, 3.13). Must match a "
|
|
336
|
+
"bootloader template in the templates directory.",
|
|
337
|
+
)
|
|
338
|
+
@click.option(
|
|
339
|
+
"--verbose", "-v",
|
|
340
|
+
is_flag=True,
|
|
341
|
+
help="Print detailed build logs including compiler tracebacks.",
|
|
342
|
+
)
|
|
343
|
+
@click.option(
|
|
344
|
+
"--quiet", "-q",
|
|
345
|
+
is_flag=True,
|
|
346
|
+
help="Suppress all output except errors.",
|
|
347
|
+
)
|
|
348
|
+
@click.option(
|
|
349
|
+
"--no-strip",
|
|
350
|
+
is_flag=True,
|
|
351
|
+
help="Preserve debug symbols in the output binary (larger file size).",
|
|
352
|
+
)
|
|
353
|
+
def build(
|
|
354
|
+
entry: Optional[str],
|
|
355
|
+
output: Optional[str],
|
|
356
|
+
optimize: Optional[str],
|
|
357
|
+
include: List[str],
|
|
358
|
+
exclude: List[str],
|
|
359
|
+
template: Optional[str],
|
|
360
|
+
config: Optional[str],
|
|
361
|
+
target: Optional[str],
|
|
362
|
+
python: Optional[str],
|
|
363
|
+
verbose: bool,
|
|
364
|
+
quiet: bool,
|
|
365
|
+
no_strip: bool,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Compile a Python application into a standalone native binary."""
|
|
368
|
+
console = _get_console(quiet)
|
|
369
|
+
if not quiet:
|
|
370
|
+
console.print("[info]Starting PyBend compilation toolchain...[/info]")
|
|
371
|
+
|
|
372
|
+
entry_explicit = entry is not None
|
|
373
|
+
cli_args = {}
|
|
374
|
+
if entry_explicit:
|
|
375
|
+
cli_args["entry_point"] = entry
|
|
376
|
+
if output is not None:
|
|
377
|
+
cli_args["output_exe"] = output
|
|
378
|
+
if optimize is not None:
|
|
379
|
+
cli_args["optimization_level"] = int(optimize)
|
|
380
|
+
if include:
|
|
381
|
+
cli_args["include_modules"] = list(include)
|
|
382
|
+
if exclude:
|
|
383
|
+
cli_args["exclude_modules"] = list(exclude)
|
|
384
|
+
if no_strip:
|
|
385
|
+
cli_args["no_strip"] = True
|
|
386
|
+
|
|
387
|
+
config_path = Path(config) if config else None
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
resolved_config = resolve_config(cli_args, config_path=config_path)
|
|
391
|
+
except ValueError as e:
|
|
392
|
+
console.print(f"[error]Configuration Error:[/error] {e}")
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
except Exception as e:
|
|
395
|
+
console.print(f"[error]Unexpected Error:[/error] {e}")
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
|
|
398
|
+
entry_point = Path(resolved_config["entry_point"])
|
|
399
|
+
auto_entry_path: Optional[Path] = None
|
|
400
|
+
|
|
401
|
+
# Detect Django project: look for manage.py relative to entry point parent or CWD
|
|
402
|
+
django_settings = detect_django(entry_point.parent) or detect_django(Path.cwd())
|
|
403
|
+
if django_settings:
|
|
404
|
+
project_pkg = django_settings.split(".")[0]
|
|
405
|
+
include_mods = resolved_config.setdefault("include_modules", [])
|
|
406
|
+
if project_pkg not in include_mods:
|
|
407
|
+
include_mods.append(project_pkg)
|
|
408
|
+
if not quiet:
|
|
409
|
+
console.print(f"[info]Including project package: {project_pkg}[/info]")
|
|
410
|
+
for extra in ["django.templatetags", "django.contrib.staticfiles",
|
|
411
|
+
"django.core.management.commands", "dj_rest_auth"]:
|
|
412
|
+
if extra not in include_mods:
|
|
413
|
+
include_mods.append(extra)
|
|
414
|
+
if not quiet:
|
|
415
|
+
console.print(f"[info]Including Django extra: {extra}[/info]")
|
|
416
|
+
|
|
417
|
+
if not entry_explicit or django_settings:
|
|
418
|
+
wrapper_code = generate_entry_code(entry_point.parent if django_settings else Path.cwd(), entry_point)
|
|
419
|
+
if wrapper_code:
|
|
420
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
421
|
+
mode="w", suffix=".py", delete=False, prefix="pybend_auto_"
|
|
422
|
+
)
|
|
423
|
+
tmp.write(wrapper_code)
|
|
424
|
+
auto_entry_path = Path(tmp.name)
|
|
425
|
+
tmp.close()
|
|
426
|
+
resolved_config["entry_point"] = str(auto_entry_path)
|
|
427
|
+
if wrapper_code:
|
|
428
|
+
resolved_config["_project_dir"] = str(entry_point.parent.resolve())
|
|
429
|
+
if not quiet:
|
|
430
|
+
console.print(f"[info]Generated framework entry wrapper[/info]")
|
|
431
|
+
entry_point = auto_entry_path
|
|
432
|
+
|
|
433
|
+
if template:
|
|
434
|
+
runner_template = Path(template).resolve()
|
|
435
|
+
else:
|
|
436
|
+
runner_template = find_default_template(target=target, python=python, quiet=quiet)
|
|
437
|
+
|
|
438
|
+
if not runner_template.exists():
|
|
439
|
+
console.print(
|
|
440
|
+
"[error]Template Error:[/error] Bootloader template not found. "
|
|
441
|
+
"Use --template to specify one, or run 'pybend bootstrap' to build from source."
|
|
442
|
+
)
|
|
443
|
+
sys.exit(3)
|
|
444
|
+
|
|
445
|
+
if not _check_libpython(runner_template):
|
|
446
|
+
console.print(
|
|
447
|
+
f"[warning]Bootloader at {runner_template} requires a different "
|
|
448
|
+
f"libpython version than your current Python ({_py_version()}).[/warning]"
|
|
449
|
+
)
|
|
450
|
+
if _check_cargo():
|
|
451
|
+
if not quiet:
|
|
452
|
+
console.print("[info]Building a matching bootloader from source...[/info]")
|
|
453
|
+
templates_dir = Path(__file__).resolve().parent / "templates"
|
|
454
|
+
built = _build_bootloader_from_source(_platform_path(templates_dir, target=target))
|
|
455
|
+
if built is not None:
|
|
456
|
+
runner_template = built
|
|
457
|
+
else:
|
|
458
|
+
console.print("[error]Auto-build failed. Try 'pybend bootstrap --force'.[/error]")
|
|
459
|
+
sys.exit(3)
|
|
460
|
+
else:
|
|
461
|
+
console.print(
|
|
462
|
+
"[error]Install Rust/Cargo (https://rustup.rs) then run "
|
|
463
|
+
"'pybend bootstrap' to build a bootloader for your Python.[/error]"
|
|
464
|
+
)
|
|
465
|
+
sys.exit(3)
|
|
466
|
+
|
|
467
|
+
entry_point = Path(resolved_config["entry_point"])
|
|
468
|
+
output_exe = Path(resolved_config["output_exe"])
|
|
469
|
+
|
|
470
|
+
if not entry_point.exists():
|
|
471
|
+
console.print(
|
|
472
|
+
f"[error]Resolution Error:[/error] Entry point script '{entry_point}' does not exist."
|
|
473
|
+
)
|
|
474
|
+
sys.exit(2)
|
|
475
|
+
|
|
476
|
+
output_exe.parent.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
|
|
478
|
+
if not quiet:
|
|
479
|
+
console.print(f"[info]Entry Point: [/info] {entry_point}")
|
|
480
|
+
console.print(f"[info]Output Target: [/info] {output_exe}")
|
|
481
|
+
console.print(
|
|
482
|
+
f"[info]Optimization: [/info] Level {resolved_config['optimization_level']}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
compile_app(resolved_config, runner_template)
|
|
487
|
+
except ImportError as e:
|
|
488
|
+
console.print(f"[error]Dependency Error:[/error] {e}")
|
|
489
|
+
sys.exit(2)
|
|
490
|
+
except FileNotFoundError as e:
|
|
491
|
+
console.print(f"[error]Compilation Error:[/error] {e}")
|
|
492
|
+
sys.exit(3)
|
|
493
|
+
except Exception as e:
|
|
494
|
+
console.print(f"[error]Build Failed:[/error] {e}")
|
|
495
|
+
if verbose:
|
|
496
|
+
import traceback
|
|
497
|
+
traceback.print_exc()
|
|
498
|
+
sys.exit(3)
|
|
499
|
+
finally:
|
|
500
|
+
if auto_entry_path and auto_entry_path.exists():
|
|
501
|
+
auto_entry_path.unlink()
|
|
502
|
+
|
|
503
|
+
if not quiet:
|
|
504
|
+
console.print(
|
|
505
|
+
f"[success]Successfully compiled '{entry_point.name}' to '{output_exe}'[/success]"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@main.command()
|
|
510
|
+
@click.option("--force", "-f", is_flag=True,
|
|
511
|
+
help="Force rebuild even if a bootloader template already exists for this version.")
|
|
512
|
+
@click.option("--target", "-p", type=str, default=None,
|
|
513
|
+
metavar="OS/ARCH",
|
|
514
|
+
help="Target platform as os/arch (e.g. linux/x86_64, macos/aarch64). "
|
|
515
|
+
"Defaults to the current build platform.")
|
|
516
|
+
@click.option("--python", "-V", type=str, default=None,
|
|
517
|
+
metavar="VERSION",
|
|
518
|
+
help="Python version string for the bootloader (e.g. 3.12). "
|
|
519
|
+
"Defaults to the version of the Python running this command.")
|
|
520
|
+
@click.option("--static", is_flag=True,
|
|
521
|
+
help="Link libpython statically if libpython.a is detected. "
|
|
522
|
+
"Produces a fully self-contained binary.")
|
|
523
|
+
def bootstrap(force: bool, target: Optional[str], python: Optional[str], static: bool) -> None:
|
|
524
|
+
"""Build the bootloader template from Rust source.
|
|
525
|
+
|
|
526
|
+
Requires Rust/Cargo (https://rustup.rs) to be installed. The compiled
|
|
527
|
+
bootloader is staged into the versioned templates directory for use
|
|
528
|
+
by 'pybend build'.
|
|
529
|
+
|
|
530
|
+
Use --force to rebuild an existing template, --target to cross-compile,
|
|
531
|
+
and --static to embed libpython for fully standalone binaries.
|
|
532
|
+
"""
|
|
533
|
+
console = _get_console()
|
|
534
|
+
if not _check_cargo():
|
|
535
|
+
console.print("[error]Rust/Cargo not found.[/error] Install from https://rustup.rs")
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
|
|
538
|
+
templates_dir = Path(__file__).resolve().parent / "templates"
|
|
539
|
+
target_dir = _platform_path(templates_dir, target=target)
|
|
540
|
+
py_ver = _py_version()
|
|
541
|
+
dest = target_dir / py_ver / _bootloader_name()
|
|
542
|
+
|
|
543
|
+
if dest.exists() and not force:
|
|
544
|
+
console.print(f"[info]Bootloader already exists at {dest}. Use --force to rebuild.[/info]")
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
built = _build_bootloader_from_source(target_dir, static=static)
|
|
548
|
+
if built is None:
|
|
549
|
+
console.print("[error]Failed to build bootloader from source.[/error]")
|
|
550
|
+
sys.exit(1)
|
|
551
|
+
console.print(f"[success]Bootloader ready at {built}[/success]")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@main.command()
|
|
555
|
+
@click.argument("binary", type=str, metavar="BINARY")
|
|
556
|
+
@click.option("--json-output", is_flag=True,
|
|
557
|
+
help="Output the VFS table as JSON for programmatic consumption.")
|
|
558
|
+
@click.option("--tree", is_flag=True,
|
|
559
|
+
help="Display the VFS entries as a tree layout instead of a table.")
|
|
560
|
+
def inspect(binary: str, json_output: bool, tree: bool) -> None:
|
|
561
|
+
"""Inspect the VFS (Virtual File System) inside a compiled PyBend binary.
|
|
562
|
+
|
|
563
|
+
Parses the embedded VFS blob and displays all code and asset entries
|
|
564
|
+
with their names, compressed/uncompressed sizes, and compression ratios.
|
|
565
|
+
|
|
566
|
+
Use --json for machine-readable output, or --tree for a hierarchical view.
|
|
567
|
+
"""
|
|
568
|
+
console = _get_console()
|
|
569
|
+
from pybend.inspector import locate_vfs_blob, parse_vfs_blob
|
|
570
|
+
|
|
571
|
+
binary_path = Path(binary).resolve()
|
|
572
|
+
if not binary_path.exists():
|
|
573
|
+
console.print(f"[error]File not found: {binary_path}[/error]")
|
|
574
|
+
sys.exit(1)
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
vfs_blob = locate_vfs_blob(binary_path)
|
|
578
|
+
except ValueError as e:
|
|
579
|
+
console.print(f"[error]{e}[/error]")
|
|
580
|
+
sys.exit(1)
|
|
581
|
+
|
|
582
|
+
info = parse_vfs_blob(vfs_blob)
|
|
583
|
+
|
|
584
|
+
console.print(f"[info]PyBend Binary:[/info] {binary_path}")
|
|
585
|
+
console.print(f"[info]VFS Total Size:[/info] {info['vfs_total_size']:,} bytes")
|
|
586
|
+
console.print(f"[info]Payload Size:[/info] {info['payload_size']:,} bytes")
|
|
587
|
+
console.print()
|
|
588
|
+
|
|
589
|
+
if info["entries"]:
|
|
590
|
+
table = Table(
|
|
591
|
+
title=f"Code Entries ({info['entry_count']})",
|
|
592
|
+
box=box.ROUNDED,
|
|
593
|
+
title_style="bold cyan",
|
|
594
|
+
)
|
|
595
|
+
table.add_column("Module", style="cyan")
|
|
596
|
+
table.add_column("Compressed", justify="right")
|
|
597
|
+
table.add_column("Uncompressed", justify="right")
|
|
598
|
+
table.add_column("Ratio", justify="right")
|
|
599
|
+
table.add_column("Type")
|
|
600
|
+
|
|
601
|
+
for e in info["entries"]:
|
|
602
|
+
ratio = (
|
|
603
|
+
f"{e['compressed_size'] / e['uncompressed_size'] * 100:.0f}%"
|
|
604
|
+
if e["uncompressed_size"]
|
|
605
|
+
else "N/A"
|
|
606
|
+
)
|
|
607
|
+
typ = "C Ext" if e["is_c_ext"] else "Python"
|
|
608
|
+
table.add_row(
|
|
609
|
+
e["name"],
|
|
610
|
+
f"{e['compressed_size']:,}",
|
|
611
|
+
f"{e['uncompressed_size']:,}",
|
|
612
|
+
ratio,
|
|
613
|
+
typ,
|
|
614
|
+
)
|
|
615
|
+
console.print(table)
|
|
616
|
+
else:
|
|
617
|
+
console.print("[warning]No code entries found in VFS.[/warning]")
|
|
618
|
+
|
|
619
|
+
if info["asset_entries"]:
|
|
620
|
+
table = Table(
|
|
621
|
+
title=f"Asset Entries ({info['asset_count']})",
|
|
622
|
+
box=box.ROUNDED,
|
|
623
|
+
title_style="bold cyan",
|
|
624
|
+
)
|
|
625
|
+
table.add_column("Path", style="green")
|
|
626
|
+
table.add_column("Compressed", justify="right")
|
|
627
|
+
table.add_column("Uncompressed", justify="right")
|
|
628
|
+
table.add_column("Ratio", justify="right")
|
|
629
|
+
|
|
630
|
+
for e in info["asset_entries"]:
|
|
631
|
+
ratio = (
|
|
632
|
+
f"{e['compressed_size'] / e['uncompressed_size'] * 100:.0f}%"
|
|
633
|
+
if e["uncompressed_size"]
|
|
634
|
+
else "N/A"
|
|
635
|
+
)
|
|
636
|
+
table.add_row(
|
|
637
|
+
e["name"],
|
|
638
|
+
f"{e['compressed_size']:,}",
|
|
639
|
+
f"{e['uncompressed_size']:,}",
|
|
640
|
+
ratio,
|
|
641
|
+
)
|
|
642
|
+
console.print(table)
|