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.
- _devkit_entry.py +59 -0
- devit_cli-0.1.0.dist-info/METADATA +273 -0
- devit_cli-0.1.0.dist-info/RECORD +52 -0
- devit_cli-0.1.0.dist-info/WHEEL +5 -0
- devit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devit_cli-0.1.0.dist-info/top_level.txt +2 -0
- devkit_cli/__init__.py +4 -0
- devkit_cli/commands/__init__.py +0 -0
- devkit_cli/commands/archive.py +166 -0
- devkit_cli/commands/clean.py +130 -0
- devkit_cli/commands/env.py +156 -0
- devkit_cli/commands/find.py +122 -0
- devkit_cli/commands/info.py +119 -0
- devkit_cli/commands/init.py +451 -0
- devkit_cli/commands/run.py +236 -0
- devkit_cli/main.py +89 -0
- devkit_cli/templates/aws/.gitignore +43 -0
- devkit_cli/templates/aws/README.md +23 -0
- devkit_cli/templates/aws/requirements.txt +2 -0
- devkit_cli/templates/aws/scripts/__init__.py +0 -0
- devkit_cli/templates/aws/scripts/ec2.py +9 -0
- devkit_cli/templates/aws/scripts/main.py +25 -0
- devkit_cli/templates/aws/scripts/s3.py +10 -0
- devkit_cli/templates/aws/tests/test_scripts.py +6 -0
- devkit_cli/templates/django/.gitignore +43 -0
- devkit_cli/templates/django/README.md +15 -0
- devkit_cli/templates/django/apps/__init__.py +0 -0
- devkit_cli/templates/django/apps/core/__init__.py +0 -0
- devkit_cli/templates/django/apps/core/apps.py +6 -0
- devkit_cli/templates/django/apps/core/urls.py +6 -0
- devkit_cli/templates/django/apps/core/views.py +7 -0
- devkit_cli/templates/django/manage.py +20 -0
- devkit_cli/templates/django/requirements.txt +1 -0
- devkit_cli/templates/django/{{module_name}}/__init__.py +0 -0
- devkit_cli/templates/django/{{module_name}}/settings.py +61 -0
- devkit_cli/templates/django/{{module_name}}/urls.py +9 -0
- devkit_cli/templates/django/{{module_name}}/wsgi.py +7 -0
- devkit_cli/templates/fastapi/.gitignore +43 -0
- devkit_cli/templates/fastapi/README.md +21 -0
- devkit_cli/templates/fastapi/app/__init__.py +0 -0
- devkit_cli/templates/fastapi/app/routers/__init__.py +0 -0
- devkit_cli/templates/fastapi/app/routers/health.py +10 -0
- devkit_cli/templates/fastapi/main.py +13 -0
- devkit_cli/templates/fastapi/requirements.txt +6 -0
- devkit_cli/templates/fastapi/tests/test_api.py +17 -0
- devkit_cli/templates/package/.gitignore +43 -0
- devkit_cli/templates/package/README.md +15 -0
- devkit_cli/templates/package/pyproject.toml +29 -0
- devkit_cli/templates/package/tests/test_core.py +8 -0
- devkit_cli/templates/package/{{module_name}}/__init__.py +3 -0
- 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)
|