gelang 0.1.0

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +535 -0
  4. package/bin/ge.js +112 -0
  5. package/package.json +62 -0
  6. package/python/pyeffic/__init__.py +9 -0
  7. package/python/pyeffic/__main__.py +6 -0
  8. package/python/pyeffic/analyzer.py +464 -0
  9. package/python/pyeffic/apisurface.py +238 -0
  10. package/python/pyeffic/autoselect.py +327 -0
  11. package/python/pyeffic/backends.py +87 -0
  12. package/python/pyeffic/bench.py +233 -0
  13. package/python/pyeffic/cli.py +184 -0
  14. package/python/pyeffic/compiler.py +421 -0
  15. package/python/pyeffic/config.py +383 -0
  16. package/python/pyeffic/dartgen.py +441 -0
  17. package/python/pyeffic/deploy.py +586 -0
  18. package/python/pyeffic/diagnostics.py +194 -0
  19. package/python/pyeffic/difftest.py +424 -0
  20. package/python/pyeffic/downloader.py +307 -0
  21. package/python/pyeffic/emitters/__init__.py +11 -0
  22. package/python/pyeffic/emitters/base.py +2359 -0
  23. package/python/pyeffic/emitters/cpp.py +266 -0
  24. package/python/pyeffic/emitters/csharp.py +342 -0
  25. package/python/pyeffic/emitters/dart.py +349 -0
  26. package/python/pyeffic/emitters/go.py +388 -0
  27. package/python/pyeffic/emitters/kotlin.py +314 -0
  28. package/python/pyeffic/emitters/rust.py +314 -0
  29. package/python/pyeffic/emitters/zig.py +411 -0
  30. package/python/pyeffic/ffi.py +49 -0
  31. package/python/pyeffic/frontends/__init__.py +94 -0
  32. package/python/pyeffic/frontends/hybrid.py +709 -0
  33. package/python/pyeffic/frontends/typescript.py +965 -0
  34. package/python/pyeffic/ge_cli.py +1148 -0
  35. package/python/pyeffic/golden.py +348 -0
  36. package/python/pyeffic/idents.py +206 -0
  37. package/python/pyeffic/modules.py +220 -0
  38. package/python/pyeffic/packer.py +222 -0
  39. package/python/pyeffic/pipeline.py +797 -0
  40. package/python/pyeffic/reactgen.py +966 -0
  41. package/python/pyeffic/researcher.py +177 -0
  42. package/python/pyeffic/scaffold.py +397 -0
  43. package/python/pyeffic/stdlib.py +246 -0
  44. package/python/pyeffic/styling.py +220 -0
  45. package/python/pyeffic/templates/desktop_gui/README.md +106 -0
  46. package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
  47. package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
  48. package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
  49. package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
  50. package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
  51. package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
  52. package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
  53. package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
  54. package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
  55. package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
  56. package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
  57. package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
  58. package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
  59. package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
  60. package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
  61. package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
  62. package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
  63. package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
  64. package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
  65. package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
  66. package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
  67. package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
  68. package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
  69. package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
  70. package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
  71. package/python/pyeffic/templates/web_react/README.md +115 -0
  72. package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
  73. package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
  74. package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
  75. package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
  76. package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
  77. package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
  78. package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
  79. package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
  80. package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
  81. package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
  82. package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
  83. package/python/pyeffic/templates/web_react/ge.toml +23 -0
  84. package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
  85. package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
  86. package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
  87. package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
  88. package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
  89. package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
  90. package/python/pyeffic/ts2py.py +657 -0
  91. package/python/pyeffic/typecheck.py +232 -0
  92. package/python/pyeffic/ui.py +154 -0
  93. package/python/pyeffic/ui_dsl.py +618 -0
  94. package/python/pyeffic/widgets.py +87 -0
  95. package/scripts/README.md +42 -0
  96. package/scripts/check-toolchains.py +85 -0
@@ -0,0 +1,220 @@
1
+ """Module/import system for GE.
2
+
3
+ Resolves `from module import function` statements by finding the
4
+ corresponding `.ge.py` file and merging its functions into the
5
+ current compilation unit.
6
+
7
+ Supported import syntax:
8
+ from mymodule import my_function
9
+ from mymodule import func1, func2
10
+ from .mymodule import func (relative import)
11
+
12
+ The module file is searched in:
13
+ 1. The same directory as the source file
14
+ 2. The GE standard library directory (future)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ from pathlib import Path
20
+ from .analyzer import FuncUnit, parse_source_full, ClassUnit, collect_constants, collect_preamble
21
+ from .frontends import frontend_for, python_flavour_of
22
+
23
+
24
+ #: Modules provided by the compiler itself. Importing them is how a source
25
+ #: file selects a backend decorator; they must never be resolved as user code.
26
+ _COMPILER_MODULES = ("pyeffic", "__future__")
27
+
28
+
29
+ def _is_compiler_module(module_name: str) -> bool:
30
+ """True if the import names a compiler-provided module."""
31
+ head = module_name.lstrip(".").split(".")[0]
32
+ return head in _COMPILER_MODULES
33
+
34
+
35
+ def _lower_source(source: str, path: Path | None = None) -> str:
36
+ flavour = frontend_for(path) if path is not None else "python"
37
+ if flavour == "typescript":
38
+ from .frontends.typescript import ts_to_python
39
+ return ts_to_python(source)
40
+ if flavour == "hybrid":
41
+ from .frontends.hybrid import hybrid_to_python
42
+ return hybrid_to_python(source)
43
+ return source
44
+
45
+
46
+ def _parse_source(source: str, path: Path | None = None) -> tuple[list[FuncUnit], list[ClassUnit]]:
47
+ """Parse source using the frontend that matches the file flavour."""
48
+ if path is not None and frontend_for(path) != "python":
49
+ from .analyzer import parse_source_full as _parse_py
50
+ return _parse_py(_lower_source(source, path))
51
+ return parse_source_full(source)
52
+
53
+
54
+ def _collect_constants(source: str, path: Path | None = None) -> dict[str, object]:
55
+ """Collect module-level constants using the matching frontend."""
56
+ if path is not None and frontend_for(path) != "python":
57
+ return collect_constants(_lower_source(source, path))
58
+ return collect_constants(source)
59
+
60
+
61
+ def _collect_preamble(source: str, path: Path | None = None) -> dict[str, str]:
62
+ """Collect ge_preamble/gePreamble blocks using the matching frontend."""
63
+ if path is not None and frontend_for(path) != "python":
64
+ return collect_preamble(_lower_source(source, path))
65
+ return collect_preamble(source)
66
+
67
+
68
+ def resolve_imports(source: str, source_path: Path) -> tuple[list[FuncUnit], list[ClassUnit], list[str]]:
69
+ """Parse source, resolve all imports (transitively), and return merged units.
70
+
71
+ Imports are followed recursively so a module that only re-exports from
72
+ other modules (an aggregator) still contributes the full function set.
73
+ Each module file is visited once; cycles are broken by the visited set.
74
+
75
+ Returns:
76
+ (units, classes, warnings)
77
+ """
78
+ warnings: list[str] = []
79
+ source_dir = source_path.parent if source_path else Path(".")
80
+
81
+ units: list[FuncUnit] = []
82
+ classes: list[ClassUnit] = []
83
+ constants: dict[str, object] = {}
84
+ preamble: dict[str, str] = {}
85
+ seen_modules: set[str] = set()
86
+ seen_units: set[str] = set()
87
+ seen_classes: set[str] = set()
88
+
89
+ def _merge_preamble(src_preamble: dict[str, str]) -> None:
90
+ for bk, code in src_preamble.items():
91
+ if bk in preamble:
92
+ preamble[bk] += "\n" + code
93
+ else:
94
+ preamble[bk] = code
95
+
96
+ def _visit(module_source: str, base_dir: Path, module_key: str, depth: int,
97
+ module_path: Path | None = None) -> None:
98
+ """Parse one module and recurse into its imports (depth-first)."""
99
+ if module_key in seen_modules or depth > 64:
100
+ return
101
+ seen_modules.add(module_key)
102
+
103
+ # Non-Python flavours are lowered before the import scan; the same
104
+ # frontend also builds the IR below.
105
+ scan_source = _lower_source(module_source, module_path)
106
+ tree = ast.parse(scan_source)
107
+
108
+ # 1) recurse into this module's imports first (dependencies before dependents)
109
+ for node in tree.body:
110
+ if not isinstance(node, ast.ImportFrom):
111
+ continue
112
+ module_name = node.module or ""
113
+ if not module_name:
114
+ continue
115
+ if _is_compiler_module(module_name):
116
+ # GE runtime modules (`from pyeffic.backends import rust`) are
117
+ # compiler intrinsics, not user code — resolving them would
118
+ # pull the compiler's own sources into the program.
119
+ continue
120
+ dep_path = _find_module(module_name, base_dir)
121
+ if dep_path is None or not dep_path.exists():
122
+ if depth == 0:
123
+ warnings.append(f"could not find module '{module_name}' at line {node.lineno}")
124
+ continue
125
+ dep_source = dep_path.read_text(encoding="utf-8")
126
+ dep_key = str(dep_path.resolve())
127
+ _visit(dep_source, dep_path.parent, dep_key, depth + 1, dep_path)
128
+
129
+ # 2) then add this module's own definitions
130
+ mod_units, mod_classes = _parse_source(module_source, module_path)
131
+ for u in mod_units:
132
+ if u.name not in seen_units:
133
+ seen_units.add(u.name)
134
+ units.append(u)
135
+ for c in mod_classes:
136
+ if c.name not in seen_classes:
137
+ seen_classes.add(c.name)
138
+ classes.append(c)
139
+ constants.update(_collect_constants(module_source, module_path))
140
+ _merge_preamble(_collect_preamble(module_source, module_path))
141
+
142
+ _visit(source, source_dir, str(source_path.resolve()) if source_path else "<source>", 0,
143
+ source_path)
144
+
145
+ globals()["_LAST_CONSTANTS"] = constants
146
+ globals()["_LAST_PREAMBLE"] = preamble
147
+
148
+ return units, classes, warnings
149
+
150
+
151
+ def get_last_constants() -> dict[str, object]:
152
+ """Return the constants collected during the last resolve_imports call."""
153
+ return globals().get("_LAST_CONSTANTS", {})
154
+
155
+
156
+ def get_last_preamble() -> dict[str, str]:
157
+ """Return the preamble collected during the last resolve_imports call."""
158
+ return globals().get("_LAST_PREAMBLE", {})
159
+
160
+
161
+ def _find_module(module_name: str, source_dir: Path) -> Path | None:
162
+ """Find a GE source file for the given module name.
163
+
164
+ Both flavours are searched, Python-like first:
165
+ app.main -> app/main.ge.py, app/main.ts.ge.py, app/main.ge.ts
166
+ """
167
+ # handle relative imports (from .module import ...)
168
+ if module_name.startswith("."):
169
+ module_name = module_name.lstrip(".")
170
+
171
+ # handle dotted module names: app.main -> app/main.ge.py
172
+ parts = module_name.split(".")
173
+ base = parts[-1]
174
+ # candidate file names for the module leaf, in preference order.
175
+ # `.ge` (hybrid) is canonical; the single-flavour variants are honoured
176
+ # next, and a plain .py is the last resort.
177
+ leaf_names = (f"{base}.ge", f"{base}.ge.py", f"{base}.ge.ts",
178
+ f"{base}.ts.ge.py", f"{base}.py")
179
+
180
+ # search in source_dir and parent directories (up to 3 levels)
181
+ search_dirs = [source_dir]
182
+ parent = source_dir.parent
183
+ for _ in range(3):
184
+ search_dirs.append(parent)
185
+ parent = parent.parent
186
+
187
+ for search_dir in search_dirs:
188
+ if len(parts) > 1:
189
+ # dotted name: only resolve relative to the package path.
190
+ # app.main -> search_dir/app/main.ge.py
191
+ # Never fall back to matching just the leaf, or `app.main` would
192
+ # wrongly resolve to a sibling `main.ge.py`.
193
+ pkg_dir = search_dir.joinpath(*parts[:-1])
194
+ for leaf in leaf_names:
195
+ candidate = pkg_dir / leaf
196
+ if candidate.exists():
197
+ return candidate
198
+ # package directory: app/main/__init__.ge
199
+ for leaf in ("__init__.ge", "__init__.ge.py", "__init__.ge.ts",
200
+ "__init__.py"):
201
+ candidate = pkg_dir / leaf
202
+ if candidate.exists():
203
+ return candidate
204
+ continue
205
+
206
+ # simple name: search_dir/<name>.<flavour>
207
+ for leaf in leaf_names:
208
+ candidate = search_dir / leaf
209
+ if candidate.exists():
210
+ return candidate
211
+
212
+ # search_dir / name / __init__.ge
213
+ pkg_dir = search_dir / module_name
214
+ for leaf in ("__init__.ge", "__init__.ge.py", "__init__.ge.ts",
215
+ "__init__.py"):
216
+ candidate = pkg_dir / leaf
217
+ if candidate.exists():
218
+ return candidate
219
+
220
+ return None
@@ -0,0 +1,222 @@
1
+ """GE packer: bundle artifacts into a single .ge distribution package.
2
+
3
+ Like npm: `ge pack` builds everything and bundles it into one `.ge` file.
4
+ `ge install package.ge` unpacks and compiles for any target (Windows, Android).
5
+
6
+ The .ge file is a zip archive containing:
7
+ package.json — metadata (name, version, ffi_exports, targets)
8
+ source.ge.py — the original GE source (for recompilation on any platform)
9
+ native/
10
+ ge_logic.rs — generated Rust source (for cross-compilation)
11
+ ge_logic.dll — pre-compiled Windows native lib (if available)
12
+ dart/
13
+ bindings.dart — generated Dart FFI bindings
14
+ main.dart — generated Flutter UI
15
+ pubspec.yaml — Flutter project config
16
+
17
+ Compression shrinks DISTRIBUTION SIZE, not runtime speed.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import zlib
24
+ import zipfile
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+
29
+ @dataclass
30
+ class FileSize:
31
+ path: Path
32
+ raw: int
33
+ packed: int
34
+
35
+ @property
36
+ def ratio(self) -> float:
37
+ return self.packed / self.raw if self.raw else 0.0
38
+
39
+
40
+ @dataclass
41
+ class PackReport:
42
+ package: Path | None = None
43
+ files: list[FileSize] = field(default_factory=list)
44
+ total_raw: int = 0
45
+ total_packed: int = 0
46
+
47
+ @property
48
+ def ratio(self) -> float:
49
+ return self.total_packed / self.total_raw if self.total_raw else 0.0
50
+
51
+
52
+ @dataclass
53
+ class PackageMeta:
54
+ """Metadata stored inside the .ge package (package.json)."""
55
+ name: str = ""
56
+ version: str = "0.1.0"
57
+ ge_version: str = "0.1.0"
58
+ app_name: str = ""
59
+ lib_name: str = "ge_logic"
60
+ backend: str = "rust"
61
+ ffi_exports: list[str] = field(default_factory=list)
62
+ targets: list[str] = field(default_factory=lambda: ["windows", "android"])
63
+ source_file: str = "source.ge.py"
64
+
65
+ def to_dict(self) -> dict:
66
+ return {
67
+ "name": self.name, "version": self.version,
68
+ "ge_version": self.ge_version, "app_name": self.app_name,
69
+ "lib_name": self.lib_name, "backend": self.backend,
70
+ "ffi_exports": self.ffi_exports, "targets": self.targets,
71
+ "source_file": self.source_file,
72
+ }
73
+
74
+ @classmethod
75
+ def from_dict(cls, d: dict) -> "PackageMeta":
76
+ return cls(
77
+ name=d.get("name", ""), version=d.get("version", "0.1.0"),
78
+ ge_version=d.get("ge_version", "0.1.0"),
79
+ app_name=d.get("app_name", ""), lib_name=d.get("lib_name", "ge_logic"),
80
+ backend=d.get("backend", "rust"),
81
+ ffi_exports=d.get("ffi_exports", []),
82
+ targets=d.get("targets", ["windows", "android"]),
83
+ source_file=d.get("source_file", "source.ge.py"),
84
+ )
85
+
86
+
87
+ def minify_source(text: str) -> str:
88
+ """Strip comments and collapse runs of whitespace. Preserves strings."""
89
+ out_lines = []
90
+ for line in text.splitlines():
91
+ stripped = re.sub(r'(?:"[^"]*")|(\s*//[^\n]*)', lambda m: m.group(1) or "", line)
92
+ stripped = re.sub(r"\s+", " ", stripped).strip()
93
+ if stripped:
94
+ out_lines.append(stripped)
95
+ return "\n".join(out_lines)
96
+
97
+
98
+ def strip_binary_size(path: Path) -> int:
99
+ return path.stat().st_size if path.exists() else 0
100
+
101
+
102
+ # ---- .ge package format (zip-based, npm-like) ----
103
+
104
+ def pack_ge(source: Path, meta: PackageMeta, artifacts: dict[str, Path],
105
+ out_path: Path, minify: bool = True) -> PackReport:
106
+ """Bundle source + generated artifacts + metadata into a single .ge zip.
107
+
108
+ artifacts: dict mapping archive path -> local file path, e.g.:
109
+ {"native/ge_logic.rs": Path("ge_build/myrent/lib/ge_logic.rs"),
110
+ "dart/bindings.dart": Path("ge_build/myrent/lib/bindings.dart"), ...}
111
+ """
112
+ report = PackReport(package=out_path)
113
+ out_path.parent.mkdir(parents=True, exist_ok=True)
114
+
115
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
116
+ # 1) metadata
117
+ meta_json = json.dumps(meta.to_dict(), indent=2)
118
+ zf.writestr("package.json", meta_json)
119
+ report.files.append(FileSize(Path("package.json"), len(meta_json), len(meta_json.encode())))
120
+ report.total_raw += len(meta_json)
121
+ report.total_packed += len(meta_json.encode())
122
+
123
+ # 2) GE source
124
+ raw = source.read_bytes()
125
+ if minify:
126
+ text = raw.decode("utf-8", errors="ignore")
127
+ mini = minify_source(text)
128
+ data = mini.encode("utf-8")
129
+ else:
130
+ data = raw
131
+ zf.writestr(meta.source_file, data)
132
+ report.files.append(FileSize(source, len(raw), len(data)))
133
+ report.total_raw += len(raw)
134
+ report.total_packed += len(data)
135
+
136
+ # 3) artifacts (native lib, Rust source, Dart files, pubspec)
137
+ for arc_name, local_path in artifacts.items():
138
+ if not local_path or not local_path.exists():
139
+ continue
140
+ raw = local_path.read_bytes()
141
+ # only minify the GE source (.py); native source (.rs/.cpp) and Dart
142
+ # must be preserved exactly for cross-compilation and Flutter
143
+ if minify and local_path.suffix == ".py":
144
+ text = raw.decode("utf-8", errors="ignore")
145
+ mini = minify_source(text)
146
+ data = mini.encode("utf-8")
147
+ else:
148
+ data = raw
149
+ zf.writestr(arc_name, data)
150
+ report.files.append(FileSize(local_path, len(raw), len(data)))
151
+ report.total_raw += len(raw)
152
+ report.total_packed += len(data)
153
+
154
+ report.total_packed = out_path.stat().st_size
155
+ return report
156
+
157
+
158
+ def pack_simple(source: Path, out_path: Path, app_name: str = "",
159
+ backend: str = "rust", minify: bool = True) -> PackReport:
160
+ """Convenience: pack a single source file into a .ge package.
161
+
162
+ This is the simplest API for the common case of bundling one .ge.py file.
163
+ """
164
+ meta = PackageMeta(
165
+ name=app_name or source.stem.replace(".ge", ""),
166
+ app_name=app_name or source.stem.replace(".ge", ""),
167
+ backend=backend,
168
+ )
169
+ return pack_ge(source, meta, {}, out_path, minify=minify)
170
+
171
+
172
+ def unpack_ge(pkg_path: Path, dest_dir: Path) -> PackageMeta:
173
+ """Extract a .ge package to dest_dir. Returns the parsed metadata."""
174
+ dest_dir.mkdir(parents=True, exist_ok=True)
175
+ with zipfile.ZipFile(pkg_path, "r") as zf:
176
+ zf.extractall(dest_dir)
177
+ meta_path = dest_dir / "package.json"
178
+ if meta_path.exists():
179
+ return PackageMeta.from_dict(json.loads(meta_path.read_text(encoding="utf-8")))
180
+ return PackageMeta()
181
+
182
+
183
+ # ---- legacy .gepkg format (backward compat) ----
184
+
185
+ def pack(files: list[Path] | Path, out_path: Path, minify: bool = True) -> PackReport:
186
+ """Bundle files into a zlib-compressed .gepkg. Returns a size report.
187
+
188
+ Accepts either a single Path or a list of Paths for convenience.
189
+ """
190
+ if isinstance(files, Path):
191
+ files = [files]
192
+ report = PackReport(package=out_path)
193
+ blob = bytearray()
194
+ for f in files:
195
+ if not f.exists():
196
+ continue
197
+ raw = f.read_bytes()
198
+ if minify and f.suffix in (".rs", ".cpp", ".dart", ".py"):
199
+ text = raw.decode("utf-8", errors="ignore")
200
+ mini = minify_source(text)
201
+ data = mini.encode("utf-8")
202
+ else:
203
+ data = raw
204
+ name = f.name.encode("utf-8")
205
+ blob += len(name).to_bytes(4, "little") + name
206
+ blob += len(data).to_bytes(4, "little") + data
207
+ report.files.append(FileSize(f, len(raw), len(data)))
208
+ report.total_raw += len(raw)
209
+ report.total_packed += len(data)
210
+
211
+ compressed = zlib.compress(bytes(blob), level=9)
212
+ out_path.write_bytes(compressed)
213
+ report.total_packed = len(compressed)
214
+ return report
215
+
216
+
217
+ def fmt_size(n: int) -> str:
218
+ if n < 1024:
219
+ return f"{n} B"
220
+ if n < 1024 * 1024:
221
+ return f"{n/1024:.1f} KB"
222
+ return f"{n/(1024*1024):.2f} MB"