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 ADDED
@@ -0,0 +1,3 @@
1
+ from pybend.importer import get_asset, BendMemoryFinder, BendMemoryLoader, BendMemoryExtensionLoader
2
+
3
+ __all__ = ["get_asset", "BendMemoryFinder", "BendMemoryLoader", "BendMemoryExtensionLoader"]
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)