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 ADDED
@@ -0,0 +1 @@
1
+ """synapseForge — scaffolding pipeline and distribution builder."""
@@ -0,0 +1,7 @@
1
+ """Init pipeline — interactive project scaffolding from template.zip.
2
+
3
+ Usage:
4
+ python -m pipeline.init [target_directory]
5
+
6
+ If no target_directory is given, the current working directory is used.
7
+ """
@@ -0,0 +1,10 @@
1
+ """Allow running as ``python -m pipeline.init [target_dir]``."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from .main import run
7
+
8
+ if __name__ == "__main__":
9
+ target = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
10
+ run(target)
@@ -0,0 +1,35 @@
1
+ """Step 5 — Save the user-provided config as ``config/replace.json`` and ``frontend/public/colors.json``."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ CONFIGURABLE_COLOR_KEYS = ("primary", "secondary", "primary_text", "gradient_secondary", "usar_gradiente")
8
+
9
+
10
+ def save_config(target: Path, config: dict) -> None:
11
+ """Write the user config to ``{target}/config/replace.json`` and colors to ``frontend/public/colors.json``.
12
+
13
+ Args:
14
+ target: Project root directory.
15
+ config: Validated user config dictionary.
16
+ """
17
+ config_dir = target / "config"
18
+ config_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ dest = config_dir / "replace.json"
21
+ with open(dest, "w", encoding="utf-8") as f:
22
+ json.dump(config, f, indent=2, ensure_ascii=False)
23
+
24
+ print(f" Saved: {dest}")
25
+
26
+ # Also generate colors.json for runtime in frontend/public/
27
+ colors = config.get("colors", {})
28
+ runtime_colors = {k: colors.get(k) for k in CONFIGURABLE_COLOR_KEYS if colors.get(k)}
29
+ if runtime_colors:
30
+ public_dir = target / "frontend" / "public"
31
+ public_dir.mkdir(parents=True, exist_ok=True)
32
+ colors_dest = public_dir / "colors.json"
33
+ with open(colors_dest, "w", encoding="utf-8") as f:
34
+ json.dump(runtime_colors, f, indent=2, ensure_ascii=False)
35
+ print(f" Saved: {colors_dest}")
@@ -0,0 +1,124 @@
1
+ """Step 1 — Interactive user input for all replace.json fields."""
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Dict, Optional
7
+
8
+
9
+ def get_user_input() -> Dict[str, object]:
10
+ """Prompt the user for every field and return a validated config dict.
11
+
12
+ Returns:
13
+ A dictionary with the same structure as ``config/replace.json``.
14
+ """
15
+ print("\nIngresá los datos del proyecto (dejá vacío para omitir):\n")
16
+
17
+ # ── Logo ────────────────────────────────────────────────────────────
18
+ logo_path: Optional[str] = _prompt("Logo de la empresa (para README, sin comillas)", required=True)
19
+ logo_resolved = _resolve_logo(logo_path)
20
+
21
+ # ── Project info ────────────────────────────────────────────────────
22
+ empresa: str = _prompt("Nombre de la empresa desarrolladora", required=True)
23
+ owner: str = _prompt("Owner del repo (usuario de GitHub)", required=True)
24
+ legal: str = _prompt("Nombre legal / razón social", required=True)
25
+ repo: str = _prompt("Nombre del repo", required=True)
26
+ cliente: str = _prompt("Nombre del cliente", required=True)
27
+ logo_cliente: str = _prompt("Logo del cliente (para la app, opcional, sin comillas)", default="")
28
+ descripcion: str = _prompt("Descripción del proyecto", required=True)
29
+ tarea: str = _prompt("Nombre de la tarea / rubro", required=True)
30
+
31
+ # ── Colores (obligatorios) ──────────────────────────────────────────
32
+ print("\nColores del proyecto:")
33
+ print(" Primary: color principal (botones, headers, burbujas)")
34
+ print(" Secondary: color secundario (hover, detalles light)")
35
+ print(" Primary Text: color de texto (botones, headers)")
36
+ print(" Gradient Secondary: color secundario del gradiente (igual al primary si no usás gradiente)\n")
37
+
38
+ colors: Dict[str, object] = {}
39
+
40
+ c = _prompt_hex("Color principal (ej: #D76F10)", required=True)
41
+ colors["primary"] = c
42
+
43
+ c = _prompt_hex("Color secundario (ej: #F0A347)", required=True)
44
+ colors["secondary"] = c
45
+
46
+ c = _prompt_hex("Color de texto (ej: #FFFFFF)", required=True)
47
+ colors["primary_text"] = c
48
+
49
+ # ── Gradient toggle ────────────────────────────────────────────────
50
+ usar = _prompt("¿Usar gradiente en botones/headers? (s/N)", default="n")
51
+ colors["usar_gradiente"] = usar.lower() in ("s", "si", "y", "yes", "1", "true")
52
+ if colors["usar_gradiente"]:
53
+ c = _prompt_hex("Color secundario del gradiente (ej: #F0A347)", required=True)
54
+ colors["gradient_secondary"] = c
55
+ else:
56
+ colors["gradient_secondary"] = colors["primary"]
57
+
58
+ return {
59
+ "logo": {
60
+ "path": str(logo_resolved),
61
+ },
62
+ "empresa": empresa,
63
+ "owner": owner,
64
+ "legal": legal,
65
+ "repo": repo,
66
+ "cliente": cliente,
67
+ "logo_cliente": logo_cliente or None,
68
+ "descripcion": descripcion,
69
+ "tarea": tarea,
70
+ "colors": colors,
71
+ }
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Prompt helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
79
+
80
+
81
+ def _prompt(label: str, *, required: bool = False, default: str = "") -> str:
82
+ """Ask for a text value. Loop until non-empty if *required*."""
83
+ hint = f" [{default}]" if default else ""
84
+ suffix = " *" if required else ""
85
+ while True:
86
+ value = input(f" {label}{suffix}{hint}: ").strip()
87
+ if not value:
88
+ value = default
89
+ if value:
90
+ return value
91
+ if not required:
92
+ return ""
93
+ print(" ⚠ Este campo es obligatorio.")
94
+
95
+
96
+ def _prompt_hex(label: str, *, required: bool = False) -> str:
97
+ """Ask for a hex color. Return hex string if provided.
98
+
99
+ Args:
100
+ label: Prompt label.
101
+ required: When True, loop until a valid hex is entered.
102
+
103
+ Returns:
104
+ The hex color string, or ``""`` if not required and skipped.
105
+ """
106
+ while True:
107
+ value = input(f" {label}: ").strip()
108
+ if not value and not required:
109
+ return ""
110
+ if not value:
111
+ print(" ⚠ Este campo es obligatorio.")
112
+ continue
113
+ if _HEX_RE.match(value):
114
+ return value
115
+ print(" ⚠ Formato inválido. Usá #RRGGBB (ej: #D76F10).")
116
+
117
+
118
+ def _resolve_logo(raw: str) -> Path:
119
+ """Resolve and validate the logo path."""
120
+ p = Path(raw).resolve()
121
+ if not p.is_file():
122
+ print(f" ⚠ Archivo no encontrado: {p}")
123
+ sys.exit(1)
124
+ return p
@@ -0,0 +1,33 @@
1
+ """Steps 6-8 — Copy logo, generate .ico, extract colors."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+
7
+
8
+ def handle_logo(config: dict, logo_dest: Path, config_key: str = "logo.path") -> None:
9
+ """Copy the user's logo to the template assets directory.
10
+
11
+ Args:
12
+ config: User config dictionary.
13
+ logo_dest: Destination path for the logo.
14
+ config_key: Dotted config key to read the source from
15
+ (``logo.path`` for company, ``logo_cliente`` for client).
16
+ """
17
+ if config_key == "logo_cliente":
18
+ logo_src_raw: Optional[str] = config.get("logo_cliente")
19
+ else:
20
+ logo_src_raw = config.get("logo", {}).get("path")
21
+
22
+ if not logo_src_raw:
23
+ print(f" WARNING: no logo path in config, skipping")
24
+ return
25
+
26
+ logo_src = Path(logo_src_raw).resolve()
27
+ if not logo_src.is_file():
28
+ print(f" WARNING: logo source not found: {logo_src}, skipping")
29
+ return
30
+
31
+ logo_dest.parent.mkdir(parents=True, exist_ok=True)
32
+ shutil.copy2(str(logo_src), str(logo_dest))
33
+ print(f" Copied: {logo_src} → {logo_dest}")
pipeline/init/main.py ADDED
@@ -0,0 +1,151 @@
1
+ """Orchestrates the full init pipeline (steps 1-10)."""
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+ from pathlib import Path
7
+
8
+ from .input_handler import get_user_input
9
+ from .template_handler import extract_template
10
+ from .venv_handler import setup_venv, install_requirements
11
+ from .config_handler import save_config
12
+ from .logo_handler import handle_logo
13
+ from .placeholder_handler import replace_all_placeholders
14
+
15
+
16
+ def run(target_dir: str, config: dict | None = None) -> None:
17
+ """Execute the full init pipeline inside *target_dir*.
18
+
19
+ When *config* is provided the pipeline uses it directly (GUI mode)
20
+ and skips the interactive ``get_user_input()`` prompt.
21
+
22
+ Args:
23
+ target_dir: Absolute or relative path to the (empty) project directory.
24
+ config: Pre-collected configuration dict (from GUI). When ``None``
25
+ the pipeline prompts via terminal.
26
+ """
27
+ target = Path(target_dir).resolve()
28
+
29
+ if not target.is_dir():
30
+ print(f" Creating directory: {target}")
31
+ target.mkdir(parents=True, exist_ok=True)
32
+
33
+ print("=" * 60)
34
+ print(" synapseForge — Init Pipeline")
35
+ print("=" * 60)
36
+
37
+ # Step 1: User input
38
+ if config is None:
39
+ print("\n[1/10] User input ...")
40
+ config = get_user_input()
41
+ else:
42
+ print("\n[1/10] Using provided configuration ...")
43
+
44
+ # Step 2: Download & extract template
45
+ print("\n[2/10] Downloading & extracting template ...")
46
+ extract_template(target)
47
+
48
+ # Step 3: Create virtual environment
49
+ print("\n[3/10] Creating virtual environment ...")
50
+ venv_path = setup_venv(target, config["repo"])
51
+
52
+ # Step 4: Install Python requirements
53
+ print("\n[4/10] Installing Python requirements ...")
54
+ install_requirements(venv_path, target)
55
+
56
+ # Step 5: npm install
57
+ print("\n[5/10] Installing npm dependencies ...")
58
+ _run_npm_install(target)
59
+
60
+ # Step 6: Copy logos
61
+ print("\n[6/10] Copying logos ...")
62
+ company_logo_dest = target / "frontend" / "src" / "assets" / "logo_empresa.png"
63
+ handle_logo(config, company_logo_dest, config_key="logo.path")
64
+ # Also copy to root/src for backward compatibility with template references
65
+ company_logo_root = target / "src" / "logo_empresa.png"
66
+ handle_logo(config, company_logo_root, config_key="logo.path")
67
+ client_logo_dest = target / "frontend" / "src" / "assets" / "logo_cliente.png"
68
+ handle_logo(config, client_logo_dest, config_key="logo_cliente")
69
+
70
+ # Step 7: Generate .ico from client logo
71
+ print("\n[7/10] Generating favicon (.ico) ...")
72
+ _run_generate_ico(venv_path, client_logo_dest)
73
+
74
+ # Step 8: Extract colors from client logo
75
+ print("\n[8/10] Resolving colors ...")
76
+ _resolve_colors(config, client_logo_dest)
77
+
78
+ # Step 9: Save user config (colors already in dict)
79
+ print("\n[9/10] Saving configuration ...")
80
+ save_config(target, config)
81
+
82
+ # Step 10: Replace placeholders
83
+ print("\n[10/10] Replacing placeholders ...")
84
+ replace_all_placeholders(target, config)
85
+
86
+ print("\n" + "=" * 60)
87
+ print(" Done! Project initialized in:", target)
88
+ print("=" * 60)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Internal helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _run_npm_install(target: Path) -> None:
97
+ """Run ``npm install`` in the frontend directory."""
98
+ frontend_dir = target / "frontend"
99
+ if not (frontend_dir / "package.json").is_file():
100
+ print(" WARNING: package.json not found, skipping npm install")
101
+ return
102
+
103
+ try:
104
+ subprocess.run(
105
+ "npm install",
106
+ cwd=str(frontend_dir),
107
+ shell=True,
108
+ check=True,
109
+ )
110
+ print(" npm install completed")
111
+ except subprocess.CalledProcessError as exc:
112
+ print(f" WARNING: npm install failed (exit code {exc.returncode})")
113
+ except FileNotFoundError:
114
+ print(" WARNING: npm not found, skipping npm install")
115
+
116
+
117
+ def _run_generate_ico(venv_path: Path, logo_png: Path) -> None:
118
+ """Generate ``.ico`` from the logo PNG using Pillow (inline)."""
119
+ if not logo_png.is_file():
120
+ print(" WARNING: logo PNG not found, skipping .ico generation")
121
+ return
122
+
123
+ try:
124
+ from PIL import Image
125
+ except ImportError:
126
+ print(" WARNING: Pillow not available, skipping .ico generation")
127
+ return
128
+
129
+ ico_path = logo_png.with_suffix(".ico")
130
+ try:
131
+ img = Image.open(str(logo_png))
132
+ sizes = [(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
133
+ img.save(str(ico_path), format="ICO", sizes=sizes)
134
+ print(f" Generated: {ico_path}")
135
+ except Exception as exc:
136
+ print(f" WARNING: .ico generation failed: {exc}")
137
+
138
+
139
+ def _resolve_colors(config: dict, logo_png: Path) -> None:
140
+ """Validate that all 4 color fields exist in config."""
141
+ keys = ["primary", "secondary", "primary_text", "gradient_secondary"]
142
+ colors = config.setdefault("colors", {})
143
+ missing = [k for k in keys if not colors.get(k)]
144
+ if missing:
145
+ print(f" WARNING: missing colors in config: {missing}")
146
+ return
147
+
148
+ for k in keys:
149
+ print(f" {k}: {colors[k]}")
150
+ usar = colors.get("usar_gradiente", True)
151
+ print(f" gradient: {'ON' if usar else 'OFF'}")
@@ -0,0 +1,162 @@
1
+ """Step 9 — Replace every XML placeholder tag in the extracted template.
2
+
3
+ Scans all text files under the target directory and replaces occurrences
4
+ of ``<tag>value</tag>`` with the actual value from the user config.
5
+
6
+ Supports nested tags such as ``<descripcion>...</descripcion>``,
7
+ ``<cliente>...</cliente>``, ``<color_primario>...</color_primario>``, etc.
8
+ """
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Dict, Pattern
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Tag → config key mapping
16
+ # ---------------------------------------------------------------------------
17
+ TAG_MAP: Dict[str, str] = {
18
+ "logo": "logo.path",
19
+ "empresa": "empresa",
20
+ "owner": "owner",
21
+ "legal": "legal",
22
+ "repo": "repo",
23
+ "cliente": "cliente",
24
+ "logo_cliente": "logo_cliente",
25
+ "descripcion": "descripcion",
26
+ "tarea": "tarea",
27
+ # Colors — 4 configurables + gradient toggle
28
+ "color_primario": "colors.primary",
29
+ "color_secundario": "colors.secondary",
30
+ "color_texto_primario": "colors.primary_text",
31
+ "color_secundario_gradiente": "colors.gradient_secondary",
32
+ }
33
+
34
+ # Regex that matches <tag>anything</tag>. Tag names must be [a-z_]+.
35
+ _TAG_RE: Pattern = re.compile(r"<([a-z_]+)>([^<]*)</\1>")
36
+
37
+
38
+ def replace_all_placeholders(target: Path, config: dict) -> None:
39
+ """Walk the entire *target* tree and replace XML placeholders.
40
+
41
+ Only processes text-like files (extensions: .py, .tsx, .ts, .js, .jsx,
42
+ .html, .css, .md, .json, .txt, .yaml, .yml, .toml, .ini, .cfg, .env).
43
+
44
+ Args:
45
+ target: Project root directory.
46
+ config: User config dictionary.
47
+ """
48
+ replacements = _build_replacement_map(config)
49
+
50
+ # Track stats for final summary
51
+ modified_files = 0
52
+ total_replacements = 0
53
+
54
+ for file_path in target.rglob("*"):
55
+ if not file_path.is_file():
56
+ continue
57
+ if not _is_text_file(file_path):
58
+ continue
59
+
60
+ # Skip node_modules, .git, __pycache__, .venv
61
+ if _should_skip(file_path):
62
+ continue
63
+
64
+ try:
65
+ original = file_path.read_bytes()
66
+ except Exception:
67
+ continue # skip binary or permission-denied
68
+
69
+ text = original.decode("utf-8", errors="replace")
70
+
71
+ new_text, count = _replace_tags(text, replacements)
72
+
73
+ if count > 0 and new_text != text:
74
+ file_path.write_bytes(new_text.encode("utf-8"))
75
+ modified_files += 1
76
+ total_replacements += count
77
+
78
+ print(f" Replaced {total_replacements} placeholder(s) across {modified_files} file(s)")
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Internal helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _build_replacement_map(config: dict) -> Dict[str, str]:
86
+ """Flatten the nested config into ``{tag: value}``."""
87
+ replacements: Dict[str, str] = {}
88
+
89
+ for tag, key_path in TAG_MAP.items():
90
+ if tag == "logo":
91
+ # Path relativo al repo desde la raíz (el logo se copia a ambos lugares)
92
+ replacements[tag] = "src/logo_empresa.png"
93
+ else:
94
+ value = _deep_get(config, key_path)
95
+ if value is not None and value != "":
96
+ replacements[tag] = str(value)
97
+
98
+ return replacements
99
+
100
+
101
+ def _deep_get(d: dict, dotted: str):
102
+ """Traverse a dict with a dotted path like ``colors.primary``."""
103
+ parts = dotted.split(".")
104
+ current: object = d
105
+ for part in parts:
106
+ if isinstance(current, dict):
107
+ current = current.get(part)
108
+ else:
109
+ return None
110
+ return current
111
+
112
+
113
+ def _replace_tags(text: str, replacements: Dict[str, str]) -> tuple:
114
+ """Replace all ``<tag>...</tag>`` spans in *text* with the mapped value.
115
+
116
+ Returns ``(new_text, count)``.
117
+ """
118
+
119
+ def _replacer(m: re.Match) -> str:
120
+ tag = m.group(1)
121
+ if tag in replacements:
122
+ return replacements[tag]
123
+ # Unrecognised tag → leave untouched
124
+ return m.group(0)
125
+
126
+ new_text, count = _TAG_RE.subn(_replacer, text)
127
+ return new_text, count
128
+
129
+
130
+ _TEXT_EXTENSIONS = frozenset({
131
+ ".py", ".tsx", ".ts", ".js", ".jsx",
132
+ ".html", ".css", ".md", ".json", ".txt",
133
+ ".yaml", ".yml", ".toml", ".ini", ".cfg",
134
+ ".env", ".env.example", ".gitignore",
135
+ ".ps1", ".bat", ".cmd", ".sh",
136
+ })
137
+
138
+ _NOEXT_NAMES = frozenset({
139
+ "LICENSE", "CHANGELOG", "CONTRIBUTORS",
140
+ })
141
+
142
+ _SKIP_DIRS = frozenset({
143
+ "node_modules", ".git", "__pycache__", ".venv",
144
+ ".synapseForge", ".vite", ".vite-temp",
145
+ })
146
+
147
+
148
+ def _is_text_file(path: Path) -> bool:
149
+ """Check extension against known text types."""
150
+ return (
151
+ path.suffix in _TEXT_EXTENSIONS
152
+ or path.name == ".gitignore"
153
+ or path.name in _NOEXT_NAMES
154
+ )
155
+
156
+
157
+ def _should_skip(path: Path) -> bool:
158
+ """Return True if the path is inside a directory that should be skipped."""
159
+ for parent in path.parents:
160
+ if parent.name in _SKIP_DIRS:
161
+ return True
162
+ return False
@@ -0,0 +1,89 @@
1
+ """Step 2 — Locate bundled (or download) template.zip and extract into target."""
2
+
3
+ import sys
4
+ import urllib.request
5
+ import zipfile
6
+ from pathlib import Path
7
+ from importlib.resources import files as resources_files
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # URL used as fallback when the bundled zip is not available.
11
+ # ---------------------------------------------------------------------------
12
+ TEMPLATE_URL = (
13
+ "https://github.com/synapse-ai-hub/synapseForge/raw/main/pipeline/template.zip"
14
+ )
15
+
16
+
17
+ def extract_template(target: Path) -> None:
18
+ """Extract the template zip into *target*.
19
+
20
+ Looks for a bundled copy first (from pip install or development mode),
21
+ otherwise downloads from the GitHub raw URL.
22
+
23
+ The bundled zip inside the installed package is **never** deleted so
24
+ that every invocation stays offline-capable.
25
+ """
26
+ bundled = _get_bundled_zip()
27
+ was_downloaded = False
28
+
29
+ if bundled is not None and bundled.is_file():
30
+ zip_path = bundled
31
+ print(f" Using bundled template: {zip_path}")
32
+ else:
33
+ zip_path = _download_template(target)
34
+ if zip_path is None:
35
+ print(" ERROR: could not obtain template.zip")
36
+ sys.exit(1)
37
+ was_downloaded = True
38
+
39
+ _safe_extract(zip_path, target)
40
+
41
+ # Only delete the zip if it was the downloaded copy —
42
+ # never touch the bundled copy inside site-packages.
43
+ if was_downloaded:
44
+ zip_path.unlink(missing_ok=True)
45
+
46
+ print(f" Extracted to: {target}")
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Internal helpers
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def _get_bundled_zip() -> Path | None:
55
+ """Locate the ``template.zip`` shipped inside the ``pipeline`` package.
56
+
57
+ Works both in development mode (``pip install -e .``) and when installed
58
+ from PyPI — the file lives in the ``pipeline`` directory of the package.
59
+ """
60
+ try:
61
+ return Path(resources_files("pipeline").joinpath("template.zip")) # type: ignore[arg-type]
62
+ except (ModuleNotFoundError, TypeError):
63
+ return None
64
+
65
+
66
+ def _download_template(target: Path) -> Path | None:
67
+ """Download template.zip from GitHub into *target*."""
68
+ try:
69
+ dest = target / "template.zip"
70
+ print(" Downloading template from GitHub …")
71
+ urllib.request.urlretrieve(TEMPLATE_URL, dest)
72
+ print(f" Downloaded: {dest}")
73
+ return dest
74
+ except Exception as exc:
75
+ print(f" Download failed: {exc}")
76
+ return None
77
+
78
+
79
+ def _safe_extract(zip_path: Path, target: Path) -> None:
80
+ """Extract zip, overwriting existing files silently."""
81
+ try:
82
+ with zipfile.ZipFile(zip_path, "r") as zf:
83
+ zf.extractall(path=target)
84
+ except zipfile.BadZipFile:
85
+ print(f" ERROR: corrupted zip file: {zip_path}")
86
+ sys.exit(1)
87
+ except Exception as exc:
88
+ print(f" ERROR extracting zip: {exc}")
89
+ sys.exit(1)
@@ -0,0 +1,68 @@
1
+ """Steps 3-4 — Create virtual environment and install dependencies."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def setup_venv(target: Path, repo_name: str) -> Path:
9
+ """Create a Python virtual environment at ``{target}/.{repo_name}``.
10
+
11
+ Args:
12
+ target: Project root directory.
13
+ repo_name: Used as the venv folder name (``.{{repo_name}}``).
14
+
15
+ Returns:
16
+ Path to the venv root.
17
+ """
18
+ venv_dir = target / f".{repo_name}"
19
+ if venv_dir.is_dir():
20
+ print(f" Virtual env already exists: {venv_dir}")
21
+ return venv_dir
22
+
23
+ result = subprocess.run(
24
+ [sys.executable, "-m", "venv", str(venv_dir)],
25
+ capture_output=True,
26
+ text=True,
27
+ )
28
+ if result.returncode != 0:
29
+ print(f" ERROR creating venv: {result.stderr.strip()}")
30
+ sys.exit(1)
31
+
32
+ print(f" Created: {venv_dir}")
33
+ return venv_dir
34
+
35
+
36
+ def install_requirements(venv_path: Path, target: Path) -> None:
37
+ """Run ``pip install -r requirements.txt`` inside the venv.
38
+
39
+ Args:
40
+ venv_path: Path to the virtual environment root.
41
+ target: Project root (where ``requirements.txt`` lives).
42
+ """
43
+ req_file = target / "requirements.txt"
44
+ if not req_file.is_file():
45
+ print(" WARNING: requirements.txt not found, skipping install")
46
+ return
47
+
48
+ python = _venv_python(venv_path)
49
+ result = subprocess.run(
50
+ [str(python), "-m", "pip", "install", "-r", str(req_file)],
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+ if result.returncode != 0:
55
+ print(f" WARNING: pip install failed:\n{result.stderr.strip()}")
56
+ return
57
+
58
+ # Print last line of pip output (usually "Successfully installed ...")
59
+ last_line = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
60
+ if last_line:
61
+ print(f" {last_line}")
62
+ else:
63
+ print(" Requirements installed.")
64
+
65
+
66
+ def _venv_python(venv_path: Path) -> Path:
67
+ """Return the path to the venv's Python executable."""
68
+ return venv_path / "Scripts" / "python.exe"