devit-cli 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.
Files changed (52) hide show
  1. _devkit_entry.py +59 -0
  2. devit_cli-0.1.0.dist-info/METADATA +273 -0
  3. devit_cli-0.1.0.dist-info/RECORD +52 -0
  4. devit_cli-0.1.0.dist-info/WHEEL +5 -0
  5. devit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  6. devit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  7. devit_cli-0.1.0.dist-info/top_level.txt +2 -0
  8. devkit_cli/__init__.py +4 -0
  9. devkit_cli/commands/__init__.py +0 -0
  10. devkit_cli/commands/archive.py +166 -0
  11. devkit_cli/commands/clean.py +130 -0
  12. devkit_cli/commands/env.py +156 -0
  13. devkit_cli/commands/find.py +122 -0
  14. devkit_cli/commands/info.py +119 -0
  15. devkit_cli/commands/init.py +451 -0
  16. devkit_cli/commands/run.py +236 -0
  17. devkit_cli/main.py +89 -0
  18. devkit_cli/templates/aws/.gitignore +43 -0
  19. devkit_cli/templates/aws/README.md +23 -0
  20. devkit_cli/templates/aws/requirements.txt +2 -0
  21. devkit_cli/templates/aws/scripts/__init__.py +0 -0
  22. devkit_cli/templates/aws/scripts/ec2.py +9 -0
  23. devkit_cli/templates/aws/scripts/main.py +25 -0
  24. devkit_cli/templates/aws/scripts/s3.py +10 -0
  25. devkit_cli/templates/aws/tests/test_scripts.py +6 -0
  26. devkit_cli/templates/django/.gitignore +43 -0
  27. devkit_cli/templates/django/README.md +15 -0
  28. devkit_cli/templates/django/apps/__init__.py +0 -0
  29. devkit_cli/templates/django/apps/core/__init__.py +0 -0
  30. devkit_cli/templates/django/apps/core/apps.py +6 -0
  31. devkit_cli/templates/django/apps/core/urls.py +6 -0
  32. devkit_cli/templates/django/apps/core/views.py +7 -0
  33. devkit_cli/templates/django/manage.py +20 -0
  34. devkit_cli/templates/django/requirements.txt +1 -0
  35. devkit_cli/templates/django/{{module_name}}/__init__.py +0 -0
  36. devkit_cli/templates/django/{{module_name}}/settings.py +61 -0
  37. devkit_cli/templates/django/{{module_name}}/urls.py +9 -0
  38. devkit_cli/templates/django/{{module_name}}/wsgi.py +7 -0
  39. devkit_cli/templates/fastapi/.gitignore +43 -0
  40. devkit_cli/templates/fastapi/README.md +21 -0
  41. devkit_cli/templates/fastapi/app/__init__.py +0 -0
  42. devkit_cli/templates/fastapi/app/routers/__init__.py +0 -0
  43. devkit_cli/templates/fastapi/app/routers/health.py +10 -0
  44. devkit_cli/templates/fastapi/main.py +13 -0
  45. devkit_cli/templates/fastapi/requirements.txt +6 -0
  46. devkit_cli/templates/fastapi/tests/test_api.py +17 -0
  47. devkit_cli/templates/package/.gitignore +43 -0
  48. devkit_cli/templates/package/README.md +15 -0
  49. devkit_cli/templates/package/pyproject.toml +29 -0
  50. devkit_cli/templates/package/tests/test_core.py +8 -0
  51. devkit_cli/templates/package/{{module_name}}/__init__.py +3 -0
  52. devkit_cli/templates/package/{{module_name}}/core.py +5 -0
@@ -0,0 +1,451 @@
1
+ """devkit init — Interactive project scaffold wizard."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import questionary
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+ from rich.table import Table
17
+
18
+ console = Console()
19
+
20
+ PROJECT_TYPES = {
21
+ "package": "Python Package (pip-installable library with pyproject.toml)",
22
+ "fastapi": "FastAPI Backend (REST API server with async support)",
23
+ "django": "Django Project (full-stack web framework)",
24
+ "aws": "AWS Scripts (Boto3 scripts, Lambda, CDK automation)",
25
+ }
26
+
27
+ ENV_TYPES = {
28
+ "venv": "Native venv (built-in, no extra tools needed)",
29
+ "conda": "Conda (Anaconda/Miniconda environment)",
30
+ "none": "Skip (set up manually later)",
31
+ }
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _run(cmd: list[str], cwd: Path | None = None, capture: bool = False):
39
+ """Run a subprocess; raises CalledProcessError on failure."""
40
+ return subprocess.run(
41
+ cmd,
42
+ cwd=cwd,
43
+ check=True,
44
+ capture_output=capture,
45
+ text=True,
46
+ )
47
+
48
+
49
+ def _create_venv(project_dir: Path, python_version: str) -> None:
50
+ """Create a native venv inside project_dir."""
51
+ with Progress(SpinnerColumn(), TextColumn("[bold green]Creating venv..."), transient=True) as p:
52
+ p.add_task("")
53
+ _run([sys.executable, "-m", "venv", ".venv"], cwd=project_dir)
54
+ console.print("[green]✔[/green] Virtual environment created at [cyan].venv/[/cyan]")
55
+
56
+
57
+ def _create_conda_env(project_dir: Path, name: str, python_version: str) -> None:
58
+ """Create a conda environment."""
59
+ conda_bin = shutil.which("conda")
60
+ if not conda_bin:
61
+ console.print("[yellow]⚠[/yellow] conda not found on PATH — skipping env creation.")
62
+ return
63
+ with Progress(SpinnerColumn(), TextColumn(f"[bold green]Creating conda env '{name}'..."), transient=True) as p:
64
+ p.add_task("")
65
+ _run([conda_bin, "create", "-n", name, f"python={python_version}", "-y"])
66
+ console.print(f"[green]✔[/green] Conda environment [cyan]{name}[/cyan] created.")
67
+
68
+
69
+ def _list_conda_envs() -> list[tuple[str, str]]:
70
+ """Return [(name, path), ...] for every conda environment found."""
71
+ conda_bin = shutil.which("conda")
72
+ if not conda_bin:
73
+ return []
74
+ try:
75
+ result = subprocess.run(
76
+ [conda_bin, "env", "list", "--json"],
77
+ capture_output=True, text=True, timeout=15,
78
+ )
79
+ data = json.loads(result.stdout)
80
+ envs = []
81
+ for env_path in data.get("envs", []):
82
+ p = Path(env_path)
83
+ envs.append((p.name, env_path))
84
+ return envs
85
+ except Exception:
86
+ return []
87
+
88
+
89
+ def _list_system_pythons() -> list[tuple[str, str]]:
90
+ """Return [(label, executable_path), ...] for Python installs on this machine."""
91
+ import platform
92
+ seen: dict[str, str] = {} # exe_path -> label
93
+
94
+ def _add(exe: str, label: str) -> None:
95
+ key = str(Path(exe).resolve())
96
+ if key not in seen:
97
+ seen[key] = label
98
+
99
+ # Current interpreter — always first
100
+ _add(sys.executable, f"Python {platform.python_version()} — current ({sys.executable})")
101
+
102
+ # Windows Python Launcher: py -0 lists every registered version
103
+ if sys.platform == "win32" and shutil.which("py"):
104
+ try:
105
+ r = subprocess.run(["py", "-0"], capture_output=True, text=True, timeout=5)
106
+ for line in (r.stdout + r.stderr).splitlines():
107
+ m = re.match(r"\s*-(\d+\.\d+)[-\w]*", line)
108
+ if m:
109
+ ver = m.group(1)
110
+ try:
111
+ path = subprocess.run(
112
+ ["py", f"-{ver}", "-c", "import sys; print(sys.executable)"],
113
+ capture_output=True, text=True, timeout=5,
114
+ ).stdout.strip()
115
+ if path:
116
+ _add(path, f"Python {ver} ({path})")
117
+ except Exception:
118
+ pass
119
+ except Exception:
120
+ pass
121
+
122
+ # python / python3 on PATH
123
+ for alias in ("python", "python3"):
124
+ exe = shutil.which(alias)
125
+ if exe:
126
+ try:
127
+ ver_out = subprocess.run(
128
+ [exe, "-c", "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro}')"],
129
+ capture_output=True, text=True, timeout=5,
130
+ ).stdout.strip()
131
+ _add(exe, f"Python {ver_out} ({exe})")
132
+ except Exception:
133
+ pass
134
+
135
+ # Linux / macOS: scan versioned executables in common locations
136
+ if sys.platform != "win32":
137
+ import glob
138
+ scan_patterns = [
139
+ "/usr/bin/python3.*",
140
+ "/usr/local/bin/python3.*",
141
+ "/opt/homebrew/bin/python3.*", # macOS Homebrew (Apple Silicon)
142
+ "/usr/local/opt/python*/bin/python3.*", # macOS Homebrew (Intel)
143
+ os.path.expanduser("~/.pyenv/versions/*/bin/python"), # pyenv
144
+ "/root/.pyenv/versions/*/bin/python",
145
+ ]
146
+ for pattern in scan_patterns:
147
+ for exe in sorted(glob.glob(pattern)):
148
+ if os.path.isfile(exe) and os.access(exe, os.X_OK):
149
+ try:
150
+ ver_out = subprocess.run(
151
+ [exe, "-c", "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro}')"],
152
+ capture_output=True, text=True, timeout=5,
153
+ ).stdout.strip()
154
+ if ver_out:
155
+ _add(exe, f"Python {ver_out} ({exe})")
156
+ except Exception:
157
+ pass
158
+
159
+ # Return as list; current interpreter is always at index 0
160
+ result = []
161
+ cur = str(Path(sys.executable).resolve())
162
+ for path, label in seen.items():
163
+ if path == cur:
164
+ result.insert(0, (label, path))
165
+ else:
166
+ result.append((label, path))
167
+ return result
168
+
169
+
170
+ def _ask_env() -> dict:
171
+ """Interactive two-step env prompt — type then create-vs-use-existing.
172
+
173
+ Returns an env-decision dict with keys:
174
+ kind : 'venv_new' | 'venv_existing' | 'conda_new' | 'conda_existing' | 'none'
175
+ python_path : str | None (venv_existing)
176
+ conda_name : str | None (conda_existing)
177
+ label : str (human-readable summary for the project table)
178
+ """
179
+ env_type = questionary.select(
180
+ "Python environment:",
181
+ choices=[questionary.Choice(title=desc, value=key) for key, desc in ENV_TYPES.items()],
182
+ ).ask()
183
+ if env_type is None:
184
+ raise click.Abort()
185
+
186
+ if env_type == "none":
187
+ return {"kind": "none", "python_path": None, "conda_name": None, "label": "None (skip)"}
188
+
189
+ # ── venv ──────────────────────────────────────────────────────────────
190
+ if env_type == "venv":
191
+ action = questionary.select(
192
+ "venv setup:",
193
+ choices=[
194
+ questionary.Choice("Create new .venv (recommended)", value="new"),
195
+ questionary.Choice("Use an existing Python interpreter on this machine", value="existing"),
196
+ ],
197
+ ).ask()
198
+ if action is None:
199
+ raise click.Abort()
200
+
201
+ if action == "new":
202
+ return {"kind": "venv_new", "python_path": None, "conda_name": None, "label": "New .venv"}
203
+
204
+ pythons = _list_system_pythons()
205
+ if not pythons:
206
+ console.print("[yellow]⚠[/yellow] No Python installations detected — creating default venv.")
207
+ return {"kind": "venv_new", "python_path": None, "conda_name": None, "label": "New .venv"}
208
+
209
+ choice = questionary.select(
210
+ "Select Python interpreter:",
211
+ choices=[questionary.Choice(title=label, value=path) for label, path in pythons],
212
+ ).ask()
213
+ if choice is None:
214
+ raise click.Abort()
215
+ # Build a tidy label from the selected entry
216
+ short = next((lbl.split("—")[0].strip() for lbl, p in pythons if p == choice), choice)
217
+ return {"kind": "venv_existing", "python_path": choice, "conda_name": None, "label": f".venv ({short})"}
218
+
219
+ # ── conda ─────────────────────────────────────────────────────────────
220
+ if env_type == "conda":
221
+ action = questionary.select(
222
+ "Conda setup:",
223
+ choices=[
224
+ questionary.Choice("Create new conda environment", value="new"),
225
+ questionary.Choice("Use an existing conda environment", value="existing"),
226
+ ],
227
+ ).ask()
228
+ if action is None:
229
+ raise click.Abort()
230
+
231
+ if action == "new":
232
+ return {"kind": "conda_new", "python_path": None, "conda_name": None, "label": "New conda env"}
233
+
234
+ envs = _list_conda_envs()
235
+ if not envs:
236
+ console.print("[yellow]⚠[/yellow] No conda environments found — will create a new one.")
237
+ return {"kind": "conda_new", "python_path": None, "conda_name": None, "label": "New conda env"}
238
+
239
+ choice = questionary.select(
240
+ "Select conda environment:",
241
+ choices=[
242
+ questionary.Choice(title=f"{name} ({path})", value=name)
243
+ for name, path in envs
244
+ ],
245
+ ).ask()
246
+ if choice is None:
247
+ raise click.Abort()
248
+ return {"kind": "conda_existing", "python_path": None, "conda_name": choice, "label": f"conda: {choice}"}
249
+
250
+ return {"kind": "none", "python_path": None, "conda_name": None, "label": "None (skip)"}
251
+
252
+
253
+ def _write_file(path: Path, content: str) -> None:
254
+ path.parent.mkdir(parents=True, exist_ok=True)
255
+ path.write_text(content, encoding="utf-8")
256
+
257
+
258
+ def _copy_template(template_name: str, dest: Path, context: dict) -> None:
259
+ """Copy a template folder to dest, substituting {{key}} placeholders in
260
+ both file contents AND in file/directory names."""
261
+ template_root = Path(__file__).parent.parent / "templates" / template_name
262
+ if not template_root.exists():
263
+ console.print(f"[red]Template '{template_name}' not found.[/red]")
264
+ return
265
+
266
+ def _resolve_name(name: str) -> str:
267
+ for key, val in context.items():
268
+ name = name.replace("{{" + key + "}}", val)
269
+ return name
270
+
271
+ for src in template_root.rglob("*"):
272
+ rel = src.relative_to(template_root)
273
+ # Substitute placeholders in every path component (dir and file names)
274
+ resolved_parts = [_resolve_name(part) for part in rel.parts]
275
+ dst = dest / Path(*resolved_parts)
276
+ if src.is_dir():
277
+ dst.mkdir(parents=True, exist_ok=True)
278
+ else:
279
+ text = src.read_text(encoding="utf-8")
280
+ for key, val in context.items():
281
+ text = text.replace("{{" + key + "}}", val)
282
+ _write_file(dst, text)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Project-type scaffold functions
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def _scaffold_package(project_dir: Path, ctx: dict) -> None:
290
+ name = ctx["project_name"]
291
+ module = name.replace("-", "_")
292
+ ctx["module_name"] = module
293
+
294
+ _copy_template("package", project_dir, ctx)
295
+
296
+ # Extra dirs
297
+ for d in ["tests", "docs"]:
298
+ (project_dir / d).mkdir(exist_ok=True)
299
+ (project_dir / d / ".gitkeep").touch()
300
+
301
+ console.print("[green]✔[/green] Python package scaffold created.")
302
+
303
+
304
+ def _scaffold_fastapi(project_dir: Path, ctx: dict) -> None:
305
+ _copy_template("fastapi", project_dir, ctx)
306
+ console.print("[green]✔[/green] FastAPI project scaffold created.")
307
+
308
+
309
+ def _scaffold_django(project_dir: Path, ctx: dict) -> None:
310
+ _copy_template("django", project_dir, ctx)
311
+ console.print("[green]✔[/green] Django project scaffold created.")
312
+
313
+
314
+ def _scaffold_aws(project_dir: Path, ctx: dict) -> None:
315
+ _copy_template("aws", project_dir, ctx)
316
+ console.print("[green]✔[/green] AWS scripts scaffold created.")
317
+
318
+
319
+ SCAFFOLD_FN = {
320
+ "package": _scaffold_package,
321
+ "fastapi": _scaffold_fastapi,
322
+ "django": _scaffold_django,
323
+ "aws": _scaffold_aws,
324
+ }
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # CLI command
329
+ # ---------------------------------------------------------------------------
330
+
331
+ @click.command()
332
+ @click.argument("project_name", required=False)
333
+ @click.option("--type", "project_type", type=click.Choice(list(PROJECT_TYPES)), default=None,
334
+ help="Project type (skip interactive prompt).")
335
+ @click.option("--env", "env_type", type=click.Choice(list(ENV_TYPES)), default=None,
336
+ help="Environment type (skip interactive prompt).")
337
+ @click.option("--python", "python_version", default="3.11", show_default=True,
338
+ help="Python version for the environment.")
339
+ @click.option("--dir", "target_dir", default=None,
340
+ help="Parent directory for the new project (default: current directory).")
341
+ @click.option("--yes", "-y", "yes", is_flag=True, default=False,
342
+ help="Skip confirmation prompt.")
343
+ def init(project_name, project_type, env_type, python_version, target_dir, yes):
344
+ """
345
+ Scaffold a new project with best-practice structure.
346
+
347
+ \b
348
+ Interactive wizard — just run `devkit init` with no arguments.
349
+ Or be explicit: devkit init my-app --type fastapi --env venv
350
+ """
351
+ console.print(Panel("[bold cyan]devkit init[/bold cyan] — New Project Wizard", expand=False))
352
+
353
+ # --- collect info interactively if not supplied via flags ---
354
+ if not project_name:
355
+ project_name = questionary.text(
356
+ "Project name:",
357
+ validate=lambda v: len(v.strip()) > 0 or "Name cannot be empty",
358
+ ).ask()
359
+ if not project_name:
360
+ raise click.Abort()
361
+ project_name = project_name.strip()
362
+
363
+ if not project_type:
364
+ project_type = questionary.select(
365
+ "What type of project?",
366
+ choices=[questionary.Choice(title=desc, value=key) for key, desc in PROJECT_TYPES.items()],
367
+ ).ask()
368
+ if not project_type:
369
+ raise click.Abort()
370
+
371
+ if env_type:
372
+ # --env flag supplied — map directly, skip sub-prompts
373
+ _kind_map = {"venv": "venv_new", "conda": "conda_new", "none": "none"}
374
+ env_decision: dict = {
375
+ "kind": _kind_map[env_type],
376
+ "python_path": None,
377
+ "conda_name": None,
378
+ "label": ENV_TYPES[env_type].split("(")[0].strip(),
379
+ }
380
+ else:
381
+ env_decision = _ask_env()
382
+
383
+ # --- confirm ---
384
+ table = Table(show_header=False, box=None, padding=(0, 2))
385
+ table.add_row("[dim]Name[/dim]", f"[bold]{project_name}[/bold]")
386
+ table.add_row("[dim]Type[/dim]", f"[cyan]{PROJECT_TYPES[project_type].split('(')[0].strip()}[/cyan]")
387
+ table.add_row("[dim]Environment[/dim]", f"[cyan]{env_decision['label']}[/cyan]")
388
+ table.add_row("[dim]Python[/dim]", python_version)
389
+ console.print(Panel(table, title="[bold]Project Summary[/bold]", expand=False))
390
+
391
+ if not yes:
392
+ ok = questionary.confirm("Create project?", default=True).ask()
393
+ if not ok:
394
+ console.print("[yellow]Aborted.[/yellow]")
395
+ raise click.Abort()
396
+
397
+ # --- create directory ---
398
+ base = Path(target_dir) if target_dir else Path.cwd()
399
+ project_dir = base / project_name
400
+
401
+ if project_dir.exists():
402
+ overwrite = questionary.confirm(
403
+ f"[yellow]'{project_dir}' already exists. Continue anyway?[/yellow]", default=False
404
+ ).ask()
405
+ if not overwrite:
406
+ raise click.Abort()
407
+ else:
408
+ project_dir.mkdir(parents=True)
409
+
410
+ # --- scaffold ---
411
+ ctx = {
412
+ "project_name": project_name,
413
+ "python_version": python_version,
414
+ "module_name": project_name.replace("-", "_"),
415
+ }
416
+ SCAFFOLD_FN[project_type](project_dir, ctx)
417
+
418
+ # --- environment ---
419
+ kind = env_decision["kind"]
420
+ if kind == "venv_new":
421
+ _create_venv(project_dir, python_version)
422
+ elif kind == "venv_existing":
423
+ py_path = env_decision["python_path"]
424
+ with Progress(SpinnerColumn(), TextColumn("[bold green]Creating .venv..."), transient=True) as p:
425
+ p.add_task("")
426
+ _run([py_path, "-m", "venv", ".venv"], cwd=project_dir)
427
+ console.print("[green]✔[/green] Virtual environment created at [cyan].venv/[/cyan]")
428
+ elif kind == "conda_new":
429
+ _create_conda_env(project_dir, project_name, python_version)
430
+ elif kind == "conda_existing":
431
+ cname = env_decision["conda_name"]
432
+ (project_dir / ".devkit").write_text(f"conda_env={cname}\n", encoding="utf-8")
433
+ console.print(f"[green]✔[/green] Linked to conda env [cyan]{cname}[/cyan].")
434
+ console.print(f" [dim]Activate with:[/dim] [bold]conda activate {cname}[/bold]")
435
+
436
+ # --- git init ---
437
+ if shutil.which("git"):
438
+ try:
439
+ _run(["git", "init"], cwd=project_dir, capture=True)
440
+ console.print("[green]✔[/green] Git repository initialised.")
441
+ except Exception:
442
+ console.print("[yellow]⚠[/yellow] git init failed — skipping.")
443
+
444
+ # --- done ---
445
+ console.print()
446
+ console.print(Panel(
447
+ f"[bold green]Project ready![/bold green]\n\n"
448
+ f" [dim]cd[/dim] [cyan]{project_name}[/cyan]\n"
449
+ f" [dim]Then run[/dim] [bold]devkit dev[/bold] [dim]to start developing.[/dim]",
450
+ expand=False,
451
+ ))
@@ -0,0 +1,236 @@
1
+ """devkit run / build / dev / test — Unified task runner (auto-detects project type)."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ console = Console()
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Project-type detection
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _detect_project_type(root: Path) -> str | None:
20
+ """Return project type key or None if unknown."""
21
+ if (root / "manage.py").exists() and (root / "manage.py").read_text(encoding="utf-8", errors="ignore").find("django") != -1:
22
+ return "django"
23
+ if (root / "manage.py").exists():
24
+ return "django"
25
+ for f in ("main.py", "app.py", "server.py", "api.py"):
26
+ fp = root / f
27
+ if fp.exists() and "fastapi" in fp.read_text(encoding="utf-8", errors="ignore").lower():
28
+ return "fastapi"
29
+ if (root / "template.yaml").exists() or (root / "template.yml").exists():
30
+ return "aws"
31
+ if (root / "cdk.json").exists() or (root / "serverless.yml").exists():
32
+ return "aws"
33
+ if (root / "pyproject.toml").exists() or (root / "setup.py").exists() or (root / "setup.cfg").exists():
34
+ return "package"
35
+ return None
36
+
37
+
38
+ def _venv_python(root: Path) -> str:
39
+ """Return path to venv python if .venv exists, else sys.executable."""
40
+ venv = root / ".venv"
41
+ if venv.exists():
42
+ win_py = venv / "Scripts" / "python.exe"
43
+ unix_py = venv / "bin" / "python"
44
+ if win_py.exists():
45
+ return str(win_py)
46
+ if unix_py.exists():
47
+ return str(unix_py)
48
+ return sys.executable
49
+
50
+
51
+ def _is_module_installed(py: str, module: str) -> bool:
52
+ """Check if a Python module is importable in the given interpreter."""
53
+ result = subprocess.run(
54
+ [py, "-c", f"import {module}"],
55
+ capture_output=True,
56
+ )
57
+ return result.returncode == 0
58
+
59
+
60
+ # Key module that must be present before run/dev can work, per project type
61
+ _SENTINEL_MODULE = {
62
+ "fastapi": "httpx", # httpx needed for TestClient; implies fastapi+uvicorn installed too
63
+ "django": "django",
64
+ "aws": "boto3",
65
+ "package": None,
66
+ }
67
+
68
+
69
+ def _ensure_deps(project_type: str, py: str, root: Path) -> None:
70
+ """Auto-install dependencies if the sentinel module is missing."""
71
+ sentinel = _SENTINEL_MODULE.get(project_type)
72
+ if not sentinel:
73
+ return
74
+ if _is_module_installed(py, sentinel):
75
+ return
76
+
77
+ console.print(
78
+ f"[yellow]⚠[/yellow] [bold]{sentinel}[/bold] not found in this environment — "
79
+ f"installing dependencies first...\n"
80
+ )
81
+
82
+ # Pick the right install command based on what exists in the project
83
+ pip = [py, "-m", "pip"]
84
+ if (root / "requirements.txt").exists():
85
+ install_cmd = pip + ["install", "-r", "requirements.txt"]
86
+ elif (root / "pyproject.toml").exists():
87
+ install_cmd = pip + ["install", "-e", ".[dev]"]
88
+ else:
89
+ console.print("[red]No requirements.txt or pyproject.toml found — cannot auto-install.[/red]")
90
+ raise SystemExit(1)
91
+
92
+ _run_cmd(install_cmd, root)
93
+ console.print()
94
+
95
+
96
+ def _run_cmd(cmd: list[str], cwd: Path) -> None:
97
+ """Run a command in subprocess, streaming output live."""
98
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
99
+ # Pre-check: is the executable reachable?
100
+ exe = cmd[0]
101
+ if not (shutil.which(exe) or Path(exe).is_file()):
102
+ console.print(
103
+ f"[red]Command not found:[/red] [bold]{exe}[/bold]\n"
104
+ f" Make sure [cyan]{exe}[/cyan] is installed and on your PATH."
105
+ )
106
+ raise SystemExit(1)
107
+ result = subprocess.run(cmd, cwd=cwd)
108
+ if result.returncode != 0:
109
+ raise SystemExit(result.returncode)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Per-project-type handlers
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _get_handlers(project_type: str, root: Path, py: str) -> dict[str, list[str] | None]:
117
+ django_manage = [py, "manage.py"]
118
+ sam = "sam"
119
+
120
+ # Always use "python -m <tool>" — works regardless of whether the tool
121
+ # binary is on PATH, as long as the package is installed in the active env.
122
+ pip = [py, "-m", "pip"]
123
+ pytest = [py, "-m", "pytest"]
124
+ uvicorn = [py, "-m", "uvicorn"]
125
+ build = [py, "-m", "build"]
126
+
127
+ # Detect entry-point for fastapi (main:app, app:app, etc.)
128
+ app_module = "main:app"
129
+ for candidate in ("main.py", "app.py", "server.py", "api.py"):
130
+ if (root / candidate).exists():
131
+ app_module = f"{Path(candidate).stem}:app"
132
+ break
133
+
134
+ if project_type == "fastapi":
135
+ return {
136
+ "dev": uvicorn + [app_module, "--reload", "--host", "127.0.0.1", "--port", "8000"],
137
+ "run": uvicorn + [app_module, "--host", "0.0.0.0", "--port", "8000"],
138
+ "build": pip + ["install", "-r", "requirements.txt"],
139
+ "test": pytest + ["tests/", "-v"],
140
+ }
141
+ elif project_type == "django":
142
+ return {
143
+ "dev": django_manage + ["runserver", "127.0.0.1:8000"],
144
+ "run": django_manage + ["runserver", "0.0.0.0:8000"],
145
+ "build": pip + ["install", "-r", "requirements.txt"],
146
+ "test": django_manage + ["test"],
147
+ }
148
+ elif project_type == "package":
149
+ return {
150
+ "dev": pip + ["install", "-e", ".[dev]"],
151
+ "run": [py, "-m", root.name.replace("-", "_")],
152
+ "build": build,
153
+ "test": pytest + ["-v"],
154
+ }
155
+ elif project_type == "aws":
156
+ has_sam = (root / "template.yaml").exists() or (root / "template.yml").exists()
157
+ return {
158
+ "dev": [sam, "local", "start-api"] if has_sam else [py, "-m", "scripts.main"],
159
+ "run": [py, "-m", "scripts.main"],
160
+ "build": [sam, "build"] if has_sam else pip + ["install", "-r", "requirements.txt"],
161
+ "test": pytest + ["tests/", "-v"],
162
+ }
163
+ return {
164
+ "dev": [py, "-m", "main"],
165
+ "run": [py, "-m", "main"],
166
+ "build": pip + ["install", "-r", "requirements.txt"],
167
+ "test": pytest + ["-v"],
168
+ }
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Shared task runner
173
+ # ---------------------------------------------------------------------------
174
+
175
+ def _run_task(task: str, extra_args: tuple, project_dir: str | None) -> None:
176
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
177
+ project_type = _detect_project_type(root)
178
+
179
+ if project_type:
180
+ console.print(f"[dim]Detected project type:[/dim] [bold cyan]{project_type}[/bold cyan]")
181
+ else:
182
+ console.print("[yellow]⚠[/yellow] Could not auto-detect project type — using generic runner.")
183
+ project_type = "generic"
184
+
185
+ py = _venv_python(root)
186
+
187
+ # Auto-install deps before run/dev/test if the key package is missing
188
+ if task in ("run", "dev", "test"):
189
+ _ensure_deps(project_type, py, root)
190
+
191
+ handlers = _get_handlers(project_type, root, py)
192
+ cmd = handlers.get(task)
193
+
194
+ if cmd is None:
195
+ console.print(f"[red]'{task}' is not supported for {project_type} projects.[/red]")
196
+ raise SystemExit(1)
197
+
198
+ full_cmd = cmd + list(extra_args)
199
+ console.print(Panel(f"[bold]{task.upper()}[/bold] — [cyan]{project_type}[/cyan]", expand=False))
200
+ _run_cmd(full_cmd, root)
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # CLI commands
205
+ # ---------------------------------------------------------------------------
206
+
207
+ @click.command()
208
+ @click.argument("extra_args", nargs=-1)
209
+ @click.option("--dir", "project_dir", default=None, help="Project root (default: cwd).")
210
+ def run(extra_args, project_dir):
211
+ """Run the project (production mode)."""
212
+ _run_task("run", extra_args, project_dir)
213
+
214
+
215
+ @click.command()
216
+ @click.argument("extra_args", nargs=-1)
217
+ @click.option("--dir", "project_dir", default=None, help="Project root (default: cwd).")
218
+ def build(extra_args, project_dir):
219
+ """Build / install the project and its dependencies."""
220
+ _run_task("build", extra_args, project_dir)
221
+
222
+
223
+ @click.command()
224
+ @click.argument("extra_args", nargs=-1)
225
+ @click.option("--dir", "project_dir", default=None, help="Project root (default: cwd).")
226
+ def dev(extra_args, project_dir):
227
+ """Start the project in development / watch mode."""
228
+ _run_task("dev", extra_args, project_dir)
229
+
230
+
231
+ @click.command()
232
+ @click.argument("extra_args", nargs=-1)
233
+ @click.option("--dir", "project_dir", default=None, help="Project root (default: cwd).")
234
+ def test(extra_args, project_dir):
235
+ """Run the test suite."""
236
+ _run_task("test", extra_args, project_dir)