synapseForge 0.1.24.dev2__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.
- pipeline/__init__.py +1 -0
- pipeline/init/__init__.py +7 -0
- pipeline/init/__main__.py +10 -0
- pipeline/init/config_handler.py +35 -0
- pipeline/init/input_handler.py +124 -0
- pipeline/init/logo_handler.py +33 -0
- pipeline/init/main.py +151 -0
- pipeline/init/placeholder_handler.py +162 -0
- pipeline/init/template_handler.py +89 -0
- pipeline/init/venv_handler.py +68 -0
- pipeline/launch/__init__.py +1 -0
- pipeline/launch/forge.py +701 -0
- pipeline/launch/templates/launcher.py +79 -0
- pipeline/template.zip +0 -0
- synapseforge/__init__.py +3 -0
- synapseforge/__main__.py +6 -0
- synapseforge/cli/__init__.py +1 -0
- synapseforge/cli/main.py +390 -0
- synapseforge/tk/__init__.py +1 -0
- synapseforge/tk/colors_app.py +305 -0
- synapseforge/tk/init_app.py +447 -0
- synapseforge/tk/logo.ico +0 -0
- synapseforge/tk/logo.png +0 -0
- synapseforge-0.1.24.dev2.dist-info/METADATA +187 -0
- synapseforge-0.1.24.dev2.dist-info/RECORD +29 -0
- synapseforge-0.1.24.dev2.dist-info/WHEEL +5 -0
- synapseforge-0.1.24.dev2.dist-info/entry_points.txt +2 -0
- synapseforge-0.1.24.dev2.dist-info/licenses/LICENSE +201 -0
- synapseforge-0.1.24.dev2.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Distribution builder — compile, PyInstaller, zip."""
|
pipeline/launch/forge.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""synapseForge - Distribution builder for agent repos.
|
|
3
|
+
|
|
4
|
+
Creates a self-contained zip with compiled backend, frontend, venv,
|
|
5
|
+
launcher executable, .env, LICENSE, and README.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python forge.py <repo_path> "<exe_name>" [--compile]
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
python forge.py D:\\ia-san-juan\\4_reinas "<cliente>nombre_cliente</cliente>"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
import zipfile
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from string import Template
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Constants
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
36
|
+
BUILD_DIR_NAME = "__forge_build__"
|
|
37
|
+
EMBEDDED_PYTHON_VERSION = "3.12.0"
|
|
38
|
+
EMBEDDED_PYTHON_URL = (
|
|
39
|
+
f"https://www.python.org/ftp/python/{EMBEDDED_PYTHON_VERSION}/"
|
|
40
|
+
f"python-{EMBEDDED_PYTHON_VERSION}-embed-amd64.zip"
|
|
41
|
+
)
|
|
42
|
+
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
|
|
43
|
+
CACHE_DIR = Path(__file__).parent / ".cache"
|
|
44
|
+
|
|
45
|
+
# Files/folders to exclude from backend after compilation
|
|
46
|
+
BACKEND_EXCLUDED_DIRS: set[str] = {
|
|
47
|
+
"__pycache__",
|
|
48
|
+
}
|
|
49
|
+
BACKEND_EXCLUDED_FILES: set[str] = {
|
|
50
|
+
"agent.db",
|
|
51
|
+
}
|
|
52
|
+
# Only keep .md/.txt inside these relative paths
|
|
53
|
+
BACKEND_KEEP_MD_TXT_PATHS: set[str] = {
|
|
54
|
+
"agent/prompts",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Logging helper
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
def _log(msg: str, *, err: bool = False) -> None:
|
|
62
|
+
tag = "ERR" if err else "INF"
|
|
63
|
+
print(f"[{tag}] {msg}", file=sys.stderr if err else sys.stdout)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _step(n: int, total: int, label: str) -> None:
|
|
67
|
+
print(f"\n{'=' * 60}")
|
|
68
|
+
print(f" Step {n}/{total}: {label}")
|
|
69
|
+
print(f"{'=' * 60}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Path helpers
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
def _repo_name(repo_path: str) -> str:
|
|
76
|
+
"""Extract the last directory component as the repo name."""
|
|
77
|
+
return Path(repo_path).resolve().name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Version extraction from frontend/package.json
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
def _get_version(repo_path: str) -> str:
|
|
84
|
+
"""Read version from frontend/package.json, fallback to '0.1.0'."""
|
|
85
|
+
pkg = Path(repo_path) / "frontend" / "package.json"
|
|
86
|
+
if pkg.is_file():
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
89
|
+
return data.get("version", "0.1.0")
|
|
90
|
+
except (json.JSONDecodeError, OSError):
|
|
91
|
+
pass
|
|
92
|
+
return "0.1.0"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Step: Download and setup embedded Python
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
def _download_file(url: str, dest: Path) -> None:
|
|
99
|
+
"""Download a file with progress indicator."""
|
|
100
|
+
_log(f" Downloading {url.split('/')[-1]} ...")
|
|
101
|
+
try:
|
|
102
|
+
urllib.request.urlretrieve(url, str(dest))
|
|
103
|
+
except urllib.error.URLError as e:
|
|
104
|
+
_log(f"Download failed: {e}", err=True)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _setup_embedded_python(repo_path: str, build_dir: Path) -> Path:
|
|
109
|
+
"""Download, configure, and install deps into embedded Python.
|
|
110
|
+
|
|
111
|
+
Returns the path to the configured python/ directory.
|
|
112
|
+
"""
|
|
113
|
+
python_dir = build_dir / "dist" / "python"
|
|
114
|
+
if python_dir.is_dir():
|
|
115
|
+
_log("Embedded Python already configured, skipping.")
|
|
116
|
+
return python_dir
|
|
117
|
+
|
|
118
|
+
# Ensure cache dir
|
|
119
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
# Step A: Download embedded Python zip (cache it)
|
|
122
|
+
embed_zip = CACHE_DIR / f"python-{EMBEDDED_PYTHON_VERSION}-embed-amd64.zip"
|
|
123
|
+
if not embed_zip.is_file():
|
|
124
|
+
_download_file(EMBEDDED_PYTHON_URL, embed_zip)
|
|
125
|
+
else:
|
|
126
|
+
_log(f" Using cached: {embed_zip.name}")
|
|
127
|
+
|
|
128
|
+
# Step B: Extract
|
|
129
|
+
_log(" Extracting embedded Python ...")
|
|
130
|
+
shutil.unpack_archive(str(embed_zip), str(python_dir))
|
|
131
|
+
|
|
132
|
+
# Step C: Configure _pth file
|
|
133
|
+
pth_file = None
|
|
134
|
+
for f in python_dir.iterdir():
|
|
135
|
+
if f.name.endswith("._pth"):
|
|
136
|
+
pth_file = f
|
|
137
|
+
break
|
|
138
|
+
if pth_file is None:
|
|
139
|
+
_log("No _pth file found in embedded Python!", err=True)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
_log(f" Configuring {pth_file.name} ...")
|
|
143
|
+
pth_lines = pth_file.read_text(encoding="utf-8").splitlines()
|
|
144
|
+
new_lines: list[str] = []
|
|
145
|
+
has_site_packages = False
|
|
146
|
+
for line in pth_lines:
|
|
147
|
+
stripped = line.strip()
|
|
148
|
+
if stripped == "#import site":
|
|
149
|
+
new_lines.append("import site")
|
|
150
|
+
elif stripped == "Lib\\site-packages":
|
|
151
|
+
has_site_packages = True
|
|
152
|
+
new_lines.append(line)
|
|
153
|
+
else:
|
|
154
|
+
new_lines.append(line)
|
|
155
|
+
if not has_site_packages:
|
|
156
|
+
new_lines.append("Lib\\site-packages")
|
|
157
|
+
pth_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
# Step D: Ensure empty DLLs dir (some packages need it)
|
|
160
|
+
dlls_dir = python_dir / "DLLs"
|
|
161
|
+
dlls_dir.mkdir(exist_ok=True)
|
|
162
|
+
|
|
163
|
+
# Step E: Install pip
|
|
164
|
+
get_pip = CACHE_DIR / "get-pip.py"
|
|
165
|
+
if not get_pip.is_file():
|
|
166
|
+
_download_file(GET_PIP_URL, get_pip)
|
|
167
|
+
|
|
168
|
+
_log(" Installing pip ...")
|
|
169
|
+
subprocess.run(
|
|
170
|
+
[str(python_dir / "python.exe"), str(get_pip)],
|
|
171
|
+
check=True, capture_output=True, text=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Step F: Install dependencies from repo requirements.txt
|
|
175
|
+
req_file = Path(repo_path) / "requirements.txt"
|
|
176
|
+
if req_file.is_file():
|
|
177
|
+
_log(" Installing dependencies from requirements.txt ...")
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
[
|
|
180
|
+
str(python_dir / "python.exe"), "-m", "pip", "install",
|
|
181
|
+
"--target", str(python_dir / "Lib" / "site-packages"),
|
|
182
|
+
"-r", str(req_file),
|
|
183
|
+
],
|
|
184
|
+
capture_output=True, text=True,
|
|
185
|
+
)
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
_log("pip install failed:", err=True)
|
|
188
|
+
for line in result.stderr.splitlines():
|
|
189
|
+
_log(f" {line}", err=True)
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
# Show installed packages count
|
|
192
|
+
lines = [l for l in result.stdout.splitlines() if "Installed" in l or "installed" in l]
|
|
193
|
+
for l in lines:
|
|
194
|
+
_log(f" {l.strip()}")
|
|
195
|
+
else:
|
|
196
|
+
_log("No requirements.txt found, skipping dep install.", err=True)
|
|
197
|
+
|
|
198
|
+
_log(f"Embedded Python ready: {python_dir}")
|
|
199
|
+
return python_dir
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Step 1: Build frontend
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
def _build_frontend(repo_path: str) -> None:
|
|
206
|
+
"""Run npm run build in the repo's frontend directory."""
|
|
207
|
+
frontend_dir = Path(repo_path) / "frontend"
|
|
208
|
+
dist_dir = frontend_dir / "dist"
|
|
209
|
+
|
|
210
|
+
if not (frontend_dir / "package.json").is_file():
|
|
211
|
+
_log("No frontend/package.json found — skipping npm build.", err=True)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
_log("Running npm install...")
|
|
215
|
+
subprocess.run(
|
|
216
|
+
"npm install",
|
|
217
|
+
cwd=str(frontend_dir),
|
|
218
|
+
check=True,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
shell=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
_log("Running npm run build (MODE=prod)...")
|
|
225
|
+
env = os.environ.copy()
|
|
226
|
+
env["VITE_MODE"] = "prod"
|
|
227
|
+
subprocess.run(
|
|
228
|
+
"npm run build",
|
|
229
|
+
cwd=str(frontend_dir),
|
|
230
|
+
check=True,
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
shell=True,
|
|
234
|
+
env=env,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not dist_dir.is_dir():
|
|
238
|
+
_log("Frontend build completed but dist/ not found!", err=True)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
|
|
241
|
+
_log(f"Frontend built: {dist_dir}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Step 2: Compile backend to .pyc
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
def _compile_backend(repo_path: str) -> None:
|
|
248
|
+
"""Compile all .py files to legacy .pyc alongside the originals."""
|
|
249
|
+
backend_dir = Path(repo_path) / "backend"
|
|
250
|
+
if not backend_dir.is_dir():
|
|
251
|
+
_log(f"backend/ not found at {backend_dir}", err=True)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
_log("Compiling backend/ to .pyc ...")
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
[sys.executable, "-m", "compileall", "-b", str(backend_dir)],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
)
|
|
260
|
+
if result.returncode != 0:
|
|
261
|
+
_log("Compilation completed with errors (may be ok).", err=True)
|
|
262
|
+
for line in result.stderr.splitlines():
|
|
263
|
+
_log(f" {line}", err=True)
|
|
264
|
+
else:
|
|
265
|
+
_log("Compilation OK.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Step 3: Create clean backend copy (exclude unwanted files)
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
def _clean_backend_copy(repo_path: str, *, compiled: bool) -> Path:
|
|
272
|
+
"""Copy backend, excluding unwanted files.
|
|
273
|
+
|
|
274
|
+
When ``compiled`` is True only ``.pyc`` files are kept (``.py`` excluded).
|
|
275
|
+
Otherwise the original ``.py`` sources are kept (``.pyc`` excluded).
|
|
276
|
+
|
|
277
|
+
Returns the path to the clean backend directory.
|
|
278
|
+
"""
|
|
279
|
+
src = Path(repo_path) / "backend"
|
|
280
|
+
dst = Path(repo_path) / BUILD_DIR_NAME / "dist" / "backend"
|
|
281
|
+
|
|
282
|
+
if dst.exists():
|
|
283
|
+
shutil.rmtree(dst)
|
|
284
|
+
|
|
285
|
+
_log(f"Copying backend (clean) -> {dst} ...")
|
|
286
|
+
|
|
287
|
+
for root, dirs, files in os.walk(str(src)):
|
|
288
|
+
rel = Path(root).relative_to(src)
|
|
289
|
+
|
|
290
|
+
# Skip excluded directories
|
|
291
|
+
dirs[:] = [d for d in dirs if d not in BACKEND_EXCLUDED_DIRS]
|
|
292
|
+
|
|
293
|
+
# Determine destination path
|
|
294
|
+
dest_dir = dst / rel
|
|
295
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
for fname in files:
|
|
298
|
+
src_file = Path(root) / fname
|
|
299
|
+
dest_file = dest_dir / fname
|
|
300
|
+
|
|
301
|
+
# Compiled build: skip .py sources (only keep .pyc).
|
|
302
|
+
# Source build: skip .pyc (only keep .py).
|
|
303
|
+
if compiled and fname.endswith(".py") and not fname.endswith(".pyc"):
|
|
304
|
+
_log(f" Excluding: {rel / fname}")
|
|
305
|
+
continue
|
|
306
|
+
if not compiled and fname.endswith(".pyc"):
|
|
307
|
+
_log(f" Excluding: {rel / fname}")
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
# Skip excluded files
|
|
311
|
+
if fname in BACKEND_EXCLUDED_FILES:
|
|
312
|
+
_log(f" Excluding: {rel / fname}")
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# For .md and .txt: only keep if in prompts/ or allowed paths
|
|
316
|
+
if fname.endswith((".md", ".txt")):
|
|
317
|
+
rel_str = str(rel.as_posix())
|
|
318
|
+
keep = any(rel_str.startswith(allowed) for allowed in BACKEND_KEEP_MD_TXT_PATHS)
|
|
319
|
+
if not keep:
|
|
320
|
+
_log(f" Excluding: {rel / fname}")
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
shutil.copy2(str(src_file), str(dest_file))
|
|
324
|
+
|
|
325
|
+
_log(f"Clean backend copy: {dst}")
|
|
326
|
+
return dst
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Step 4: Generate launcher and build executable
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
def _generate_launcher(repo_path: str, exe_name: str) -> str:
|
|
333
|
+
"""Generate a customized launcher.py from the template.
|
|
334
|
+
|
|
335
|
+
Returns the path to the generated launcher file.
|
|
336
|
+
"""
|
|
337
|
+
template_file = TEMPLATES_DIR / "launcher.py"
|
|
338
|
+
if not template_file.is_file():
|
|
339
|
+
_log(f"Launcher template not found: {template_file}", err=True)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
|
|
342
|
+
template_src = template_file.read_text(encoding="utf-8")
|
|
343
|
+
|
|
344
|
+
# Replace placeholders
|
|
345
|
+
launcher_src = (
|
|
346
|
+
template_src
|
|
347
|
+
.replace("{{APP_MODULE}}", "backend.main:app")
|
|
348
|
+
.replace("{{PORT}}", "8000")
|
|
349
|
+
.replace("{{EXE_NAME}}", exe_name)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
build_dir = Path(repo_path) / BUILD_DIR_NAME
|
|
353
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
launcher_path = build_dir / "launcher.py"
|
|
355
|
+
launcher_path.write_text(launcher_src, encoding="utf-8")
|
|
356
|
+
|
|
357
|
+
_log(f"Launcher generated: {launcher_path}")
|
|
358
|
+
return str(launcher_path)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _build_executable(repo_path: str, launcher_path: str, exe_name: str) -> Path:
|
|
362
|
+
"""Run PyInstaller to create the executable.
|
|
363
|
+
|
|
364
|
+
Returns the path to the generated executable.
|
|
365
|
+
"""
|
|
366
|
+
build_dir = Path(repo_path) / BUILD_DIR_NAME
|
|
367
|
+
|
|
368
|
+
# Determine icon path (try common locations)
|
|
369
|
+
icon_candidates = [
|
|
370
|
+
Path(repo_path) / "frontend" / "src" / "assets" / " logo_cliente.ico",
|
|
371
|
+
Path(repo_path) / "frontend" / "public" / "logo.ico",
|
|
372
|
+
Path(repo_path) / "logo.ico",
|
|
373
|
+
]
|
|
374
|
+
icon_path = None
|
|
375
|
+
for cand in icon_candidates:
|
|
376
|
+
if cand.is_file():
|
|
377
|
+
icon_path = cand
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
_log(f"Building executable '{exe_name}.exe' with PyInstaller ...")
|
|
381
|
+
|
|
382
|
+
cmd = [
|
|
383
|
+
sys.executable, "-m", "PyInstaller",
|
|
384
|
+
"--clean",
|
|
385
|
+
"--onefile",
|
|
386
|
+
"--noconsole",
|
|
387
|
+
"--name", exe_name,
|
|
388
|
+
"--distpath", str(build_dir / "dist"),
|
|
389
|
+
"--workpath", str(build_dir / "temp"),
|
|
390
|
+
"--specpath", str(build_dir / "temp"),
|
|
391
|
+
]
|
|
392
|
+
if icon_path:
|
|
393
|
+
cmd.extend(["--icon", str(icon_path)])
|
|
394
|
+
|
|
395
|
+
cmd.append(launcher_path)
|
|
396
|
+
|
|
397
|
+
_log(f" PyInstaller command: {' '.join(cmd)}")
|
|
398
|
+
|
|
399
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
400
|
+
if result.returncode != 0:
|
|
401
|
+
_log("PyInstaller build FAILED:", err=True)
|
|
402
|
+
for line in result.stderr.splitlines():
|
|
403
|
+
_log(f" {line}", err=True)
|
|
404
|
+
for line in result.stdout.splitlines():
|
|
405
|
+
if "ERROR" in line or "Error" in line or "Traceback" in line:
|
|
406
|
+
_log(f" {line}", err=True)
|
|
407
|
+
sys.exit(1)
|
|
408
|
+
|
|
409
|
+
exe_path = build_dir / "dist" / f"{exe_name}.exe"
|
|
410
|
+
if not exe_path.is_file():
|
|
411
|
+
_log(f"Executable not found at {exe_path}", err=True)
|
|
412
|
+
sys.exit(1)
|
|
413
|
+
|
|
414
|
+
_log(f"Executable: {exe_path} ({exe_path.stat().st_size / 1024 / 1024:.1f} MB)")
|
|
415
|
+
return exe_path
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# Step 5: Build zip
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
def _build_zip(repo_path: str, exe_path: Path, exe_name: str) -> Path:
|
|
422
|
+
"""Package everything into a zip file.
|
|
423
|
+
|
|
424
|
+
Contents:
|
|
425
|
+
- {exe_name}.exe
|
|
426
|
+
- backend/ (compiled .pyc, cleaned)
|
|
427
|
+
- frontend/dist/ (from repo)
|
|
428
|
+
- python/ (embedded Python + deps)
|
|
429
|
+
- .env (from repo)
|
|
430
|
+
- LICENSE (from repo)
|
|
431
|
+
- README.md (from repo)
|
|
432
|
+
"""
|
|
433
|
+
repo = Path(repo_path)
|
|
434
|
+
version = _get_version(repo_path)
|
|
435
|
+
repo_name = _repo_name(repo_path)
|
|
436
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
437
|
+
zip_name = f"{repo_name}-v{version}.zip"
|
|
438
|
+
zip_path = Path(repo_path) / BUILD_DIR_NAME / zip_name
|
|
439
|
+
|
|
440
|
+
_log(f"Creating zip: {zip_name} ...")
|
|
441
|
+
|
|
442
|
+
with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
|
443
|
+
entry_count = 0
|
|
444
|
+
|
|
445
|
+
# 1. Executable
|
|
446
|
+
zf.write(str(exe_path), exe_path.name)
|
|
447
|
+
entry_count += 1
|
|
448
|
+
_log(f" Added: {exe_path.name}")
|
|
449
|
+
|
|
450
|
+
# 2. backend/ (cleaned copy)
|
|
451
|
+
backend_src = Path(repo_path) / BUILD_DIR_NAME / "dist" / "backend"
|
|
452
|
+
if backend_src.is_dir():
|
|
453
|
+
for root, dirs, files in os.walk(str(backend_src)):
|
|
454
|
+
rel = Path(root).relative_to(backend_src)
|
|
455
|
+
for fname in files:
|
|
456
|
+
arcname = f"backend/{rel / fname}"
|
|
457
|
+
zf.write(os.path.join(root, fname), arcname)
|
|
458
|
+
entry_count += 1
|
|
459
|
+
_log(f" Added: backend/ (cleaned, .pyc)")
|
|
460
|
+
|
|
461
|
+
# 3. frontend/dist/
|
|
462
|
+
frontend_dist = repo / "frontend" / "dist"
|
|
463
|
+
if frontend_dist.is_dir():
|
|
464
|
+
for root, dirs, files in os.walk(str(frontend_dist)):
|
|
465
|
+
rel = Path(root).relative_to(frontend_dist)
|
|
466
|
+
for fname in files:
|
|
467
|
+
arcname = f"frontend/dist/{rel / fname}"
|
|
468
|
+
zf.write(os.path.join(root, fname), arcname)
|
|
469
|
+
entry_count += 1
|
|
470
|
+
_log(f" Added: frontend/dist/")
|
|
471
|
+
|
|
472
|
+
# 4. Embedded Python (portable, no hardcoded paths)
|
|
473
|
+
python_src = Path(repo_path) / BUILD_DIR_NAME / "dist" / "python"
|
|
474
|
+
if python_src.is_dir():
|
|
475
|
+
for root, dirs, files in os.walk(str(python_src)):
|
|
476
|
+
rel = Path(root).relative_to(python_src)
|
|
477
|
+
# Skip __pycache__
|
|
478
|
+
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
|
479
|
+
for fname in files:
|
|
480
|
+
arcname = f"python/{rel / fname}"
|
|
481
|
+
zf.write(os.path.join(root, fname), arcname)
|
|
482
|
+
entry_count += 1
|
|
483
|
+
_log(f" Added: python/ (embedded Python + deps)")
|
|
484
|
+
|
|
485
|
+
# 5. .env
|
|
486
|
+
env_file = repo / ".env"
|
|
487
|
+
if env_file.is_file():
|
|
488
|
+
zf.write(str(env_file), ".env")
|
|
489
|
+
entry_count += 1
|
|
490
|
+
_log(f" Added: .env")
|
|
491
|
+
|
|
492
|
+
# 6. LICENSE
|
|
493
|
+
license_file = repo / "LICENSE"
|
|
494
|
+
if license_file.is_file():
|
|
495
|
+
zf.write(str(license_file), "LICENSE")
|
|
496
|
+
entry_count += 1
|
|
497
|
+
_log(f" Added: LICENSE")
|
|
498
|
+
|
|
499
|
+
# 7. README.md
|
|
500
|
+
readme_file = repo / "README.md"
|
|
501
|
+
if readme_file.is_file():
|
|
502
|
+
zf.write(str(readme_file), "README.md")
|
|
503
|
+
entry_count += 1
|
|
504
|
+
_log(f" Added: README.md")
|
|
505
|
+
|
|
506
|
+
_log(f" Total entries: {entry_count}")
|
|
507
|
+
|
|
508
|
+
_log(f"\nZip created: {zip_path} ({zip_path.stat().st_size / 1024 / 1024:.1f} MB)")
|
|
509
|
+
return zip_path
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# Cleanup
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
def _cleanup(repo_path: str, *, keep_zip: bool = True, compiled: bool = True) -> Path | None:
|
|
516
|
+
"""Remove build artifacts from repo. Returns final zip path if kept."""
|
|
517
|
+
build_dir = Path(repo_path) / BUILD_DIR_NAME
|
|
518
|
+
final_zip: Path | None = None
|
|
519
|
+
|
|
520
|
+
if not build_dir.is_dir():
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
# Keep only the zip if requested
|
|
524
|
+
if keep_zip:
|
|
525
|
+
zip_files = list(build_dir.glob("*.zip"))
|
|
526
|
+
# Move zip files out first, THEN delete the build directory
|
|
527
|
+
for zf in zip_files:
|
|
528
|
+
dest = Path(repo_path) / zf.name
|
|
529
|
+
shutil.move(str(zf), str(dest))
|
|
530
|
+
final_zip = dest
|
|
531
|
+
_log(f"Zip moved to: {dest}")
|
|
532
|
+
shutil.rmtree(str(build_dir))
|
|
533
|
+
|
|
534
|
+
# Also clean .pyc files from original backend (only if we compiled them)
|
|
535
|
+
backend_dir = Path(repo_path) / "backend"
|
|
536
|
+
if compiled and backend_dir.is_dir():
|
|
537
|
+
_log("Cleaning .pyc files from original backend/ ...")
|
|
538
|
+
for pyc in backend_dir.rglob("*.pyc"):
|
|
539
|
+
pyc.unlink(missing_ok=True)
|
|
540
|
+
|
|
541
|
+
return final_zip
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# Main orchestrator
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
def build(
|
|
548
|
+
repo_path: str,
|
|
549
|
+
exe_name: str,
|
|
550
|
+
*,
|
|
551
|
+
skip_frontend: bool = False,
|
|
552
|
+
use_embed: bool = True,
|
|
553
|
+
compile_backend: bool = False,
|
|
554
|
+
) -> Path | None:
|
|
555
|
+
"""Run the full build pipeline.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
repo_path: Absolute path to the repository root.
|
|
559
|
+
exe_name: Name for the generated executable.
|
|
560
|
+
skip_frontend: Skip npm build and reuse existing ``frontend/dist``.
|
|
561
|
+
use_embed: Download/configure embedded Python instead of using a venv.
|
|
562
|
+
compile_backend: Compile ``backend/`` to ``.pyc`` (default: ship
|
|
563
|
+
the ``.py`` sources as-is).
|
|
564
|
+
|
|
565
|
+
Returns the path to the generated zip file, or None if build failed.
|
|
566
|
+
"""
|
|
567
|
+
repo_path = str(Path(repo_path).resolve())
|
|
568
|
+
total_steps = 7 if not skip_frontend else 6
|
|
569
|
+
final_zip: Path | None = None
|
|
570
|
+
build_dir = Path(repo_path) / BUILD_DIR_NAME
|
|
571
|
+
|
|
572
|
+
_log(f"Starting build for: {repo_path}")
|
|
573
|
+
_log(f"Executable name: {exe_name}")
|
|
574
|
+
_log(f"Repo name: {_repo_name(repo_path)}")
|
|
575
|
+
_log(f"Version: {_get_version(repo_path)}")
|
|
576
|
+
_log(f"Python: embedded ({EMBEDDED_PYTHON_VERSION})" if use_embed else f"Python: venv (.{{repo}})")
|
|
577
|
+
_log(f"Download: {EMBEDDED_PYTHON_URL}" if use_embed else "")
|
|
578
|
+
_log(f"Backend: {'compiled (.pyc)' if compile_backend else 'source (.py)'}")
|
|
579
|
+
|
|
580
|
+
# Validate
|
|
581
|
+
if not Path(repo_path).is_dir():
|
|
582
|
+
_log(f"Repo path not found: {repo_path}", err=True)
|
|
583
|
+
sys.exit(1)
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
current_step = 0
|
|
587
|
+
|
|
588
|
+
# Step 1 — Build frontend
|
|
589
|
+
if not skip_frontend:
|
|
590
|
+
current_step += 1
|
|
591
|
+
_step(current_step, total_steps, "Build frontend (npm run build)")
|
|
592
|
+
_build_frontend(repo_path)
|
|
593
|
+
|
|
594
|
+
# Step 2 — Compile backend (optional)
|
|
595
|
+
if compile_backend:
|
|
596
|
+
current_step += 1
|
|
597
|
+
_step(current_step, total_steps, "Compile backend to .pyc")
|
|
598
|
+
_compile_backend(repo_path)
|
|
599
|
+
|
|
600
|
+
# Step 3 — Clean backend copy
|
|
601
|
+
current_step += 1
|
|
602
|
+
_step(current_step, total_steps, "Create clean backend copy")
|
|
603
|
+
_clean_backend_copy(repo_path, compiled=compile_backend)
|
|
604
|
+
|
|
605
|
+
# Step 4 — Setup embedded Python (download, pip, deps)
|
|
606
|
+
if use_embed:
|
|
607
|
+
current_step += 1
|
|
608
|
+
_step(current_step, total_steps, "Setup embedded Python + dependencies")
|
|
609
|
+
_setup_embedded_python(repo_path, build_dir)
|
|
610
|
+
else:
|
|
611
|
+
_log("Using existing venv (--no-embed).")
|
|
612
|
+
|
|
613
|
+
# Step 5 — Generate launcher
|
|
614
|
+
current_step += 1
|
|
615
|
+
_step(current_step, total_steps, "Generate launcher and build executable")
|
|
616
|
+
launcher_path = _generate_launcher(repo_path, exe_name)
|
|
617
|
+
|
|
618
|
+
# Step 6 — Build executable
|
|
619
|
+
current_step += 1
|
|
620
|
+
_step(current_step, total_steps, "Build executable with PyInstaller")
|
|
621
|
+
exe_path = _build_executable(repo_path, launcher_path, exe_name)
|
|
622
|
+
|
|
623
|
+
# Step 7 — Create zip & cleanup
|
|
624
|
+
current_step += 1
|
|
625
|
+
_step(current_step, total_steps, "Package distribution zip")
|
|
626
|
+
zip_path = _build_zip(repo_path, exe_path, exe_name)
|
|
627
|
+
_log("Cleaning build artifacts ...")
|
|
628
|
+
final_zip = _cleanup(repo_path, keep_zip=True, compiled=compile_backend)
|
|
629
|
+
|
|
630
|
+
print(f"\n{'=' * 60}")
|
|
631
|
+
print(f" BUILD COMPLETE")
|
|
632
|
+
print(f" Output: {final_zip}")
|
|
633
|
+
print(f"{'=' * 60}\n")
|
|
634
|
+
|
|
635
|
+
except Exception:
|
|
636
|
+
_log("Build FAILED — cleaning temporary files ...", err=True)
|
|
637
|
+
_cleanup(repo_path, keep_zip=False, compiled=compile_backend)
|
|
638
|
+
raise
|
|
639
|
+
|
|
640
|
+
return final_zip
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
# CLI entry point
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
def main() -> None:
|
|
647
|
+
parser = argparse.ArgumentParser(
|
|
648
|
+
description="synapseForge - Build distribution zip for agent repos",
|
|
649
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
650
|
+
epilog=(
|
|
651
|
+
"Examples:\n"
|
|
652
|
+
" python forge.py D:\\ia-san-juan\\4_reinas \"<cliente>nombre_cliente</cliente>\"\n"
|
|
653
|
+
" python forge.py /home/user/my-repo \"Mi App\" --skip-frontend\n"
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
parser.add_argument(
|
|
657
|
+
"repo_path",
|
|
658
|
+
help="Absolute path to the repository root",
|
|
659
|
+
)
|
|
660
|
+
parser.add_argument(
|
|
661
|
+
"exe_name",
|
|
662
|
+
help="Name for the executable (e.g. '<cliente>nombre_cliente</cliente>')",
|
|
663
|
+
)
|
|
664
|
+
parser.add_argument(
|
|
665
|
+
"--skip-frontend",
|
|
666
|
+
action="store_true",
|
|
667
|
+
help="Skip frontend build (use existing dist/)",
|
|
668
|
+
)
|
|
669
|
+
parser.add_argument(
|
|
670
|
+
"--no-embed",
|
|
671
|
+
action="store_true",
|
|
672
|
+
help="Use existing venv instead of downloading embedded Python",
|
|
673
|
+
)
|
|
674
|
+
parser.add_argument(
|
|
675
|
+
"--compile",
|
|
676
|
+
"-c",
|
|
677
|
+
action="store_true",
|
|
678
|
+
help="Compile backend/ to .pyc (default: ship .py sources as-is)",
|
|
679
|
+
)
|
|
680
|
+
parser.add_argument(
|
|
681
|
+
"--output",
|
|
682
|
+
"-o",
|
|
683
|
+
help="Output directory for the zip (default: repo root)",
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
args = parser.parse_args()
|
|
687
|
+
try:
|
|
688
|
+
build(
|
|
689
|
+
args.repo_path,
|
|
690
|
+
args.exe_name,
|
|
691
|
+
skip_frontend=args.skip_frontend,
|
|
692
|
+
use_embed=not args.no_embed,
|
|
693
|
+
compile_backend=args.compile,
|
|
694
|
+
)
|
|
695
|
+
except Exception as e:
|
|
696
|
+
_log(f"ERROR: {e}", err=True)
|
|
697
|
+
sys.exit(1)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
if __name__ == "__main__":
|
|
701
|
+
main()
|