newstack 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.
- newstack/__init__.py +4 -0
- newstack/__main__.py +8 -0
- newstack/cli.py +387 -0
- newstack/data/stacks/fastapi/stack.json +42 -0
- newstack/data/stacks/fastapi/template/.github/workflows/ci.yml +30 -0
- newstack/data/stacks/fastapi/template/.gitignore +11 -0
- newstack/data/stacks/fastapi/template/Dockerfile +13 -0
- newstack/data/stacks/fastapi/template/LICENSE +21 -0
- newstack/data/stacks/fastapi/template/README.md +30 -0
- newstack/data/stacks/fastapi/template/pyproject.toml +31 -0
- newstack/data/stacks/fastapi/template/src/{{package_name}}/__init__.py +4 -0
- newstack/data/stacks/fastapi/template/src/{{package_name}}/main.py +50 -0
- newstack/data/stacks/fastapi/template/tests/test_api.py +41 -0
- newstack/data/stacks/node-express-ts/stack.json +14 -0
- newstack/data/stacks/node-express-ts/template/.github/workflows/ci.yml +28 -0
- newstack/data/stacks/node-express-ts/template/.gitignore +6 -0
- newstack/data/stacks/node-express-ts/template/LICENSE +21 -0
- newstack/data/stacks/node-express-ts/template/README.md +24 -0
- newstack/data/stacks/node-express-ts/template/package.json +26 -0
- newstack/data/stacks/node-express-ts/template/src/app.ts +35 -0
- newstack/data/stacks/node-express-ts/template/src/server.ts +7 -0
- newstack/data/stacks/node-express-ts/template/tests/app.test.ts +32 -0
- newstack/data/stacks/node-express-ts/template/tsconfig.json +14 -0
- newstack/data/stacks/python-cli/stack.json +50 -0
- newstack/data/stacks/python-cli/template/.github/workflows/ci.yml +32 -0
- newstack/data/stacks/python-cli/template/.gitignore +11 -0
- newstack/data/stacks/python-cli/template/LICENSE +21 -0
- newstack/data/stacks/python-cli/template/README.md +35 -0
- newstack/data/stacks/python-cli/template/pyproject.toml +32 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/__init__.py +4 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/__main__.py +6 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/cli.py +30 -0
- newstack/data/stacks/python-cli/template/tests/test_cli.py +20 -0
- newstack/data/stacks/python-lib/stack.json +42 -0
- newstack/data/stacks/python-lib/template/.github/workflows/ci.yml +32 -0
- newstack/data/stacks/python-lib/template/.gitignore +11 -0
- newstack/data/stacks/python-lib/template/LICENSE +21 -0
- newstack/data/stacks/python-lib/template/README.md +28 -0
- newstack/data/stacks/python-lib/template/pyproject.toml +34 -0
- newstack/data/stacks/python-lib/template/src/{{package_name}}/__init__.py +6 -0
- newstack/data/stacks/python-lib/template/src/{{package_name}}/core.py +23 -0
- newstack/data/stacks/python-lib/template/tests/test_core.py +31 -0
- newstack/data/stacks/react-vite-ts/stack.json +15 -0
- newstack/data/stacks/react-vite-ts/template/.github/workflows/ci.yml +29 -0
- newstack/data/stacks/react-vite-ts/template/.gitignore +7 -0
- newstack/data/stacks/react-vite-ts/template/LICENSE +21 -0
- newstack/data/stacks/react-vite-ts/template/README.md +25 -0
- newstack/data/stacks/react-vite-ts/template/index.html +12 -0
- newstack/data/stacks/react-vite-ts/template/package.json +28 -0
- newstack/data/stacks/react-vite-ts/template/src/App.test.tsx +20 -0
- newstack/data/stacks/react-vite-ts/template/src/App.tsx +15 -0
- newstack/data/stacks/react-vite-ts/template/src/main.tsx +16 -0
- newstack/data/stacks/react-vite-ts/template/src/styles.css +26 -0
- newstack/data/stacks/react-vite-ts/template/tsconfig.json +16 -0
- newstack/data/stacks/react-vite-ts/template/vite.config.ts +10 -0
- newstack/data/stacks/static-site/stack.json +14 -0
- newstack/data/stacks/static-site/template/.github/workflows/deploy-pages.yml +30 -0
- newstack/data/stacks/static-site/template/.gitignore +2 -0
- newstack/data/stacks/static-site/template/LICENSE +21 -0
- newstack/data/stacks/static-site/template/README.md +21 -0
- newstack/data/stacks/static-site/template/index.html +34 -0
- newstack/data/stacks/static-site/template/main.js +15 -0
- newstack/data/stacks/static-site/template/styles.css +74 -0
- newstack/data/stacks/static-site/template/tests/site.test.mjs +22 -0
- newstack/registry.py +137 -0
- newstack/render.py +163 -0
- newstack/verify.py +133 -0
- newstack-0.1.0.dist-info/METADATA +197 -0
- newstack-0.1.0.dist-info/RECORD +72 -0
- newstack-0.1.0.dist-info/WHEEL +4 -0
- newstack-0.1.0.dist-info/entry_points.txt +2 -0
- newstack-0.1.0.dist-info/licenses/LICENSE +21 -0
newstack/__init__.py
ADDED
newstack/__main__.py
ADDED
newstack/cli.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Command line interface for newstack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .registry import RegistryError, Stack, get_stack, load_stacks
|
|
16
|
+
from .render import TemplateError, generate
|
|
17
|
+
from .verify import check_stack, missing_tools
|
|
18
|
+
|
|
19
|
+
TOOLCHAIN_CHECKS = {
|
|
20
|
+
"python3": "Python",
|
|
21
|
+
"uv": "uv (Python package manager)",
|
|
22
|
+
"git": "Git",
|
|
23
|
+
"gh": "GitHub CLI",
|
|
24
|
+
"node": "Node.js",
|
|
25
|
+
"npm": "npm",
|
|
26
|
+
"pnpm": "pnpm",
|
|
27
|
+
"go": "Go",
|
|
28
|
+
"cargo": "Rust / Cargo",
|
|
29
|
+
"docker": "Docker",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PY_RESERVED = {
|
|
33
|
+
"and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif",
|
|
34
|
+
"else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda",
|
|
35
|
+
"nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield",
|
|
36
|
+
} # fmt: skip
|
|
37
|
+
|
|
38
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- helpers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _package_name(name: str) -> str:
|
|
45
|
+
slug = re.sub(r"[^0-9A-Za-z]+", "_", name).strip("_").lower()
|
|
46
|
+
if not slug:
|
|
47
|
+
slug = "app"
|
|
48
|
+
if slug[0].isdigit():
|
|
49
|
+
slug = f"app_{slug}"
|
|
50
|
+
if slug in PY_RESERVED:
|
|
51
|
+
slug = f"{slug}_app"
|
|
52
|
+
return slug
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _npm_name(name: str) -> str:
|
|
56
|
+
slug = re.sub(r"[^a-z0-9._-]+", "-", name.lower()).strip("-._")
|
|
57
|
+
return slug or "app"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _title(name: str) -> str:
|
|
61
|
+
words = re.split(r"[-_.\s]+", name)
|
|
62
|
+
return " ".join(word[:1].upper() + word[1:] for word in words if word)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _git_config(key: str) -> str:
|
|
66
|
+
try:
|
|
67
|
+
value = subprocess.run(
|
|
68
|
+
["git", "config", "--get", key], capture_output=True, text=True, check=False
|
|
69
|
+
).stdout.strip()
|
|
70
|
+
except OSError:
|
|
71
|
+
return ""
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_context(stack: Stack, destination: Path, args: argparse.Namespace) -> dict:
|
|
76
|
+
project_name = args.name or destination.name
|
|
77
|
+
author = args.author or _git_config("user.name") or "Your Name"
|
|
78
|
+
email = args.email or _git_config("user.email") or "noreply@example.com"
|
|
79
|
+
today = datetime.now(timezone.utc).date()
|
|
80
|
+
return {
|
|
81
|
+
"project_name": project_name,
|
|
82
|
+
"package_name": _package_name(project_name),
|
|
83
|
+
"npm_name": _npm_name(project_name),
|
|
84
|
+
"title": _title(project_name),
|
|
85
|
+
"description": args.description or stack.default_description,
|
|
86
|
+
"author": author,
|
|
87
|
+
"email": email,
|
|
88
|
+
"year": today.year,
|
|
89
|
+
"date": today.isoformat(),
|
|
90
|
+
"stack": stack.name,
|
|
91
|
+
"stack_title": stack.title,
|
|
92
|
+
"newstack_version": __version__,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_name(name: str, what: str = "project name") -> None:
|
|
97
|
+
if not name:
|
|
98
|
+
raise TemplateError(f"{what} cannot be empty")
|
|
99
|
+
if len(name) > 64:
|
|
100
|
+
raise TemplateError(f"{what} is longer than 64 characters")
|
|
101
|
+
if not NAME_RE.match(name):
|
|
102
|
+
raise TemplateError(
|
|
103
|
+
f"invalid {what} '{name}': use letters, digits, dots, dashes or underscores"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run(cmd: list[str], cwd: Path) -> tuple[bool, str]:
|
|
108
|
+
try:
|
|
109
|
+
completed = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
return False, str(exc)
|
|
112
|
+
if completed.returncode != 0:
|
|
113
|
+
return False, (completed.stderr or completed.stdout).strip()
|
|
114
|
+
return True, completed.stdout.strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- commands
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_new(args: argparse.Namespace) -> int:
|
|
121
|
+
stack = get_stack(args.stack)
|
|
122
|
+
destination = Path(args.path).expanduser().resolve()
|
|
123
|
+
context = build_context(stack, destination, args)
|
|
124
|
+
_validate_name(context["project_name"])
|
|
125
|
+
|
|
126
|
+
result = generate(
|
|
127
|
+
stack.template_dir,
|
|
128
|
+
destination,
|
|
129
|
+
context,
|
|
130
|
+
force=args.force,
|
|
131
|
+
dry_run=args.dry_run,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
report: dict = {
|
|
135
|
+
"stack": stack.name,
|
|
136
|
+
"path": str(destination),
|
|
137
|
+
"dry_run": args.dry_run,
|
|
138
|
+
"files": result.files,
|
|
139
|
+
"overwritten": result.overwritten,
|
|
140
|
+
"git": None,
|
|
141
|
+
"github": None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if not args.dry_run and not args.no_git and shutil.which("git"):
|
|
145
|
+
ok, out = _run(["git", "init", "-b", "main"], destination)
|
|
146
|
+
if ok:
|
|
147
|
+
_run(["git", "add", "-A"], destination)
|
|
148
|
+
ok, out = _run(
|
|
149
|
+
[
|
|
150
|
+
"git",
|
|
151
|
+
"-c",
|
|
152
|
+
f"user.name={context['author']}",
|
|
153
|
+
"-c",
|
|
154
|
+
f"user.email={context['email']}",
|
|
155
|
+
"commit",
|
|
156
|
+
"-m",
|
|
157
|
+
"Initial commit from newstack",
|
|
158
|
+
],
|
|
159
|
+
destination,
|
|
160
|
+
)
|
|
161
|
+
report["git"] = {"initialized": True, "commit": ok, "detail": out}
|
|
162
|
+
else:
|
|
163
|
+
report["git"] = {"initialized": False, "detail": out}
|
|
164
|
+
|
|
165
|
+
if args.github and not args.dry_run:
|
|
166
|
+
slug = args.github if isinstance(args.github, str) else context["project_name"]
|
|
167
|
+
if not shutil.which("gh"):
|
|
168
|
+
raise TemplateError("--github needs the GitHub CLI (gh) on PATH")
|
|
169
|
+
cmd = ["gh", "repo", "create", slug, "--source", ".", "--push"]
|
|
170
|
+
cmd.append("--private" if args.private else "--public")
|
|
171
|
+
ok, out = _run(cmd, destination)
|
|
172
|
+
if not ok:
|
|
173
|
+
raise TemplateError(f"gh repo create failed: {out}")
|
|
174
|
+
ok, url = _run(["gh", "repo", "view", slug, "--json", "url", "--jq", ".url"], destination)
|
|
175
|
+
report["github"] = url if ok else out
|
|
176
|
+
|
|
177
|
+
if args.json:
|
|
178
|
+
print(json.dumps(report, indent=2))
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
_print_new_report(stack, destination, context, result, report)
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _print_new_report(
|
|
186
|
+
stack: Stack, destination: Path, context: dict, result, report: dict
|
|
187
|
+
) -> None:
|
|
188
|
+
print(f"\nnewstack {__version__} · {stack.name} → {destination}\n")
|
|
189
|
+
verb = "would create" if result.dry_run else "created"
|
|
190
|
+
print(f" {verb:<13} {result.count} files")
|
|
191
|
+
for name in result.files:
|
|
192
|
+
print(f" {'+' if not result.dry_run else '·'} {name}")
|
|
193
|
+
if result.overwritten:
|
|
194
|
+
names = ", ".join(result.overwritten)
|
|
195
|
+
print(f" {'overwritten':<13} {len(result.overwritten)} existing files ({names})")
|
|
196
|
+
git = report.get("git")
|
|
197
|
+
if git and git.get("initialized") and git.get("commit"):
|
|
198
|
+
print(f" {'git':<13} initialised on main, first commit made")
|
|
199
|
+
if report.get("github"):
|
|
200
|
+
print(f" {'github':<13} {report['github']}")
|
|
201
|
+
|
|
202
|
+
if stack.next_steps:
|
|
203
|
+
steps = [
|
|
204
|
+
step.format(
|
|
205
|
+
path=destination,
|
|
206
|
+
package_name=context["package_name"],
|
|
207
|
+
npm_name=context["npm_name"],
|
|
208
|
+
project_name=context["project_name"],
|
|
209
|
+
)
|
|
210
|
+
for step in stack.next_steps
|
|
211
|
+
]
|
|
212
|
+
print("\n next:")
|
|
213
|
+
for step in steps:
|
|
214
|
+
print(f" {step}")
|
|
215
|
+
print()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
219
|
+
stacks = load_stacks()
|
|
220
|
+
if args.json:
|
|
221
|
+
print(
|
|
222
|
+
json.dumps(
|
|
223
|
+
[
|
|
224
|
+
{
|
|
225
|
+
"name": s.name,
|
|
226
|
+
"title": s.title,
|
|
227
|
+
"language": s.language,
|
|
228
|
+
"blurb": s.blurb,
|
|
229
|
+
"requires": list(s.requires),
|
|
230
|
+
"available": not missing_tools(s),
|
|
231
|
+
"missing_tools": missing_tools(s),
|
|
232
|
+
}
|
|
233
|
+
for s in stacks.values()
|
|
234
|
+
],
|
|
235
|
+
indent=2,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
width = max((len(s.name) for s in stacks.values()), default=8)
|
|
241
|
+
print(f"\n{len(stacks)} stacks available\n")
|
|
242
|
+
for stack in stacks.values():
|
|
243
|
+
absent = missing_tools(stack)
|
|
244
|
+
flag = "" if not absent else f" (needs {', '.join(absent)})"
|
|
245
|
+
print(f" {stack.name:<{width}} {stack.language:<8} {stack.blurb}{flag}")
|
|
246
|
+
print("\n scaffold one: newstack new <stack> <path>\n")
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
251
|
+
names = list(args.stacks or [])
|
|
252
|
+
if args.all:
|
|
253
|
+
names = list(load_stacks())
|
|
254
|
+
if not names:
|
|
255
|
+
raise RegistryError("name at least one stack, or pass --all")
|
|
256
|
+
|
|
257
|
+
results = []
|
|
258
|
+
for name in names:
|
|
259
|
+
stack = get_stack(name)
|
|
260
|
+
context = build_context_for_check(stack, args.name)
|
|
261
|
+
absent = missing_tools(stack)
|
|
262
|
+
if absent and not args.force:
|
|
263
|
+
print(f" {stack.name:<16} SKIP missing toolchain: {', '.join(absent)}")
|
|
264
|
+
continue
|
|
265
|
+
print(f" {stack.name:<16} running…", end="", flush=True)
|
|
266
|
+
result = check_stack(stack, context, keep=args.keep, verbose=args.verbose)
|
|
267
|
+
results.append(result)
|
|
268
|
+
status = "PASS" if result.ok else "FAIL"
|
|
269
|
+
print(f"\r {stack.name:<16} {status} {result.seconds:5.1f}s "
|
|
270
|
+
f"{len(result.outcomes)} step(s) ")
|
|
271
|
+
for outcome in result.outcomes:
|
|
272
|
+
if outcome.skipped:
|
|
273
|
+
print(f" - {outcome.name}: skipped ({outcome.skipped})")
|
|
274
|
+
elif not outcome.ok:
|
|
275
|
+
print(f" ✗ {outcome.name} failed in {outcome.seconds:.1f}s")
|
|
276
|
+
if outcome.output:
|
|
277
|
+
for line in outcome.output.splitlines()[-25:]:
|
|
278
|
+
print(f" {line}")
|
|
279
|
+
elif args.verbose:
|
|
280
|
+
print(f" ✓ {outcome.name} ({outcome.seconds:.1f}s)")
|
|
281
|
+
if args.keep:
|
|
282
|
+
print(f" generated project kept at {result.directory}")
|
|
283
|
+
|
|
284
|
+
if args.json:
|
|
285
|
+
print(json.dumps([r.as_dict() for r in results], indent=2))
|
|
286
|
+
return 0 if results and all(r.ok for r in results) else 1
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_context_for_check(stack: Stack, name: str | None) -> dict:
|
|
290
|
+
args = argparse.Namespace(
|
|
291
|
+
name=name or "check-app", description=None, author="newstack CI", email=None
|
|
292
|
+
)
|
|
293
|
+
return build_context(stack, Path(args.name), args)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
297
|
+
print(f"\nnewstack {__version__} · python {sys.version.split()[0]}\n")
|
|
298
|
+
for tool, label in TOOLCHAIN_CHECKS.items():
|
|
299
|
+
path = shutil.which(tool)
|
|
300
|
+
mark = "✓" if path else "·"
|
|
301
|
+
print(f" {mark} {tool:<8} {label:<28} {path or 'not found'}")
|
|
302
|
+
stacks = load_stacks()
|
|
303
|
+
ready = [s.name for s in stacks.values() if not missing_tools(s)]
|
|
304
|
+
blocked = {s.name: missing_tools(s) for s in stacks.values() if missing_tools(s)}
|
|
305
|
+
print(f"\n {len(ready)}/{len(stacks)} stacks can be verified on this machine")
|
|
306
|
+
for name, absent in blocked.items():
|
|
307
|
+
print(f" ! {name}: needs {', '.join(absent)}")
|
|
308
|
+
print()
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
313
|
+
print(__version__ if not args.json else json.dumps({"version": __version__}))
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --------------------------------------------------------------------------- parser
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
321
|
+
parser = argparse.ArgumentParser(
|
|
322
|
+
prog="newstack",
|
|
323
|
+
description="Scaffold a new project on any popular stack — every starter is CI-green.",
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument("--version", action="version", version=f"newstack {__version__}")
|
|
326
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
327
|
+
|
|
328
|
+
new = sub.add_parser("new", help="create a project from a stack")
|
|
329
|
+
new.add_argument("stack", help="stack name (see `newstack list`)")
|
|
330
|
+
new.add_argument("path", help="directory to create")
|
|
331
|
+
new.add_argument("--name", help="project name (defaults to the directory name)")
|
|
332
|
+
new.add_argument("-d", "--description", help="one-line project description")
|
|
333
|
+
new.add_argument("--author", help="author name (defaults to git config user.name)")
|
|
334
|
+
new.add_argument("--email", help="author email (defaults to git config user.email)")
|
|
335
|
+
new.add_argument(
|
|
336
|
+
"--force", action="store_true", help="overwrite files in a non-empty directory"
|
|
337
|
+
)
|
|
338
|
+
new.add_argument("--dry-run", action="store_true", help="show what would be created")
|
|
339
|
+
new.add_argument("--no-git", action="store_true", help="skip git init and first commit")
|
|
340
|
+
new.add_argument(
|
|
341
|
+
"--github",
|
|
342
|
+
nargs="?",
|
|
343
|
+
const=True,
|
|
344
|
+
default=False,
|
|
345
|
+
metavar="OWNER/REPO",
|
|
346
|
+
help="create a GitHub repo from the project and push (needs gh)",
|
|
347
|
+
)
|
|
348
|
+
new.add_argument("--private", action="store_true", help="make the GitHub repo private")
|
|
349
|
+
new.add_argument("--json", action="store_true", help="machine readable output")
|
|
350
|
+
new.set_defaults(handler=cmd_new)
|
|
351
|
+
|
|
352
|
+
lst = sub.add_parser("list", help="list available stacks")
|
|
353
|
+
lst.add_argument("--json", action="store_true")
|
|
354
|
+
lst.set_defaults(handler=cmd_list)
|
|
355
|
+
|
|
356
|
+
check = sub.add_parser("check", help="generate a stack in a temp dir and run its checks")
|
|
357
|
+
check.add_argument("stacks", nargs="*", help="stack names")
|
|
358
|
+
check.add_argument("--all", action="store_true", help="check every stack")
|
|
359
|
+
check.add_argument("--keep", action="store_true", help="keep the generated project")
|
|
360
|
+
check.add_argument("--verbose", action="store_true", help="show passing step output")
|
|
361
|
+
check.add_argument("--force", action="store_true", help="run even if tools look missing")
|
|
362
|
+
check.add_argument("--name", help="project name for the generated sample")
|
|
363
|
+
check.add_argument("--json", action="store_true")
|
|
364
|
+
check.set_defaults(handler=cmd_check)
|
|
365
|
+
|
|
366
|
+
doctor = sub.add_parser("doctor", help="show which toolchains are installed")
|
|
367
|
+
doctor.set_defaults(handler=cmd_doctor)
|
|
368
|
+
|
|
369
|
+
version = sub.add_parser("version", help="print the version")
|
|
370
|
+
version.add_argument("--json", action="store_true")
|
|
371
|
+
version.set_defaults(handler=cmd_version)
|
|
372
|
+
|
|
373
|
+
return parser
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main(argv: list[str] | None = None) -> int:
|
|
377
|
+
parser = build_parser()
|
|
378
|
+
args = parser.parse_args(argv)
|
|
379
|
+
try:
|
|
380
|
+
return int(args.handler(args))
|
|
381
|
+
except (RegistryError, TemplateError) as exc:
|
|
382
|
+
print(f"newstack: error: {exc}", file=sys.stderr)
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
sys.exit(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fastapi",
|
|
3
|
+
"title": "FastAPI service",
|
|
4
|
+
"language": "python",
|
|
5
|
+
"blurb": "Typed JSON API: pydantic models, TestClient tests, Dockerfile",
|
|
6
|
+
"requires": [
|
|
7
|
+
"uv",
|
|
8
|
+
"git"
|
|
9
|
+
],
|
|
10
|
+
"default_description": "A FastAPI service.",
|
|
11
|
+
"next_steps": [
|
|
12
|
+
"cd {path}",
|
|
13
|
+
"uv sync",
|
|
14
|
+
"uv run uvicorn {package_name}.main:app --reload"
|
|
15
|
+
],
|
|
16
|
+
"verify": [
|
|
17
|
+
{
|
|
18
|
+
"name": "install",
|
|
19
|
+
"cmd": "uv venv -q .venv && uv pip install -q --python .venv/bin/python -e . pytest httpx ruff",
|
|
20
|
+
"tools": [
|
|
21
|
+
"uv"
|
|
22
|
+
],
|
|
23
|
+
"timeout": 900
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "ruff",
|
|
27
|
+
"cmd": ".venv/bin/ruff check .",
|
|
28
|
+
"tools": [
|
|
29
|
+
"uv"
|
|
30
|
+
],
|
|
31
|
+
"timeout": 300
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "pytest",
|
|
35
|
+
"cmd": ".venv/bin/python -m pytest -q",
|
|
36
|
+
"tools": [
|
|
37
|
+
"uv"
|
|
38
|
+
],
|
|
39
|
+
"timeout": 300
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- name: Set up uv
|
|
21
|
+
uses: astral-sh/setup-uv@v10.3.3
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
enable-cache: true
|
|
25
|
+
- name: Install
|
|
26
|
+
run: uv sync --group dev
|
|
27
|
+
- name: Lint
|
|
28
|
+
run: uv run ruff check .
|
|
29
|
+
- name: Test
|
|
30
|
+
run: uv run pytest
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
4
|
+
PYTHONUNBUFFERED=1
|
|
5
|
+
|
|
6
|
+
WORKDIR /app
|
|
7
|
+
|
|
8
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
9
|
+
COPY src ./src
|
|
10
|
+
RUN pip install --no-cache-dir .
|
|
11
|
+
|
|
12
|
+
EXPOSE 8000
|
|
13
|
+
CMD ["uvicorn", "{{ package_name }}.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) {{ year }} {{ author }}
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{ title }}
|
|
2
|
+
|
|
3
|
+
{{ description }}
|
|
4
|
+
|
|
5
|
+
A small typed JSON API: `GET /health`, `GET /items`, `POST /items`, `GET /items/{item_id}`.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv sync
|
|
11
|
+
uv run uvicorn {{ package_name }}.main:app --reload
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then open <http://127.0.0.1:8000/docs> for the interactive OpenAPI docs.
|
|
15
|
+
|
|
16
|
+
## Test
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv run pytest
|
|
20
|
+
uv run ruff check .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Docker
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
docker build -t {{ npm_name }} .
|
|
27
|
+
docker run -p 8000:8000 {{ npm_name }}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Generated with [newstack](https://github.com/DawnofGenX/newstack).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "{{ npm_name }}"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "{{ description }}"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "{{ author }}", email = "{{ email }}" }]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi>=0.115",
|
|
16
|
+
"uvicorn[standard]>=0.30",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=8.0", "httpx>=0.27", "ruff>=0.6"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/{{ package_name }}"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
target-version = "py310"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
addopts = "-q"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""HTTP API for {{ title }}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import count
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ItemIn(BaseModel):
|
|
12
|
+
name: str = Field(min_length=1, max_length=100)
|
|
13
|
+
price: float = Field(ge=0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Item(ItemIn):
|
|
17
|
+
id: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app() -> FastAPI:
|
|
21
|
+
"""Build the application. A factory keeps tests isolated from each other."""
|
|
22
|
+
app = FastAPI(title="{{ title }}", version="0.1.0")
|
|
23
|
+
items: dict[int, Item] = {}
|
|
24
|
+
ids = count(1)
|
|
25
|
+
|
|
26
|
+
@app.get("/health")
|
|
27
|
+
def health() -> dict[str, str]:
|
|
28
|
+
return {"status": "ok"}
|
|
29
|
+
|
|
30
|
+
@app.get("/items")
|
|
31
|
+
def list_items() -> list[Item]:
|
|
32
|
+
return list(items.values())
|
|
33
|
+
|
|
34
|
+
@app.post("/items", status_code=201)
|
|
35
|
+
def create_item(payload: ItemIn) -> Item:
|
|
36
|
+
item = Item(id=next(ids), **payload.model_dump())
|
|
37
|
+
items[item.id] = item
|
|
38
|
+
return item
|
|
39
|
+
|
|
40
|
+
@app.get("/items/{item_id}")
|
|
41
|
+
def get_item(item_id: int) -> Item:
|
|
42
|
+
try:
|
|
43
|
+
return items[item_id]
|
|
44
|
+
except KeyError:
|
|
45
|
+
raise HTTPException(status_code=404, detail="item not found") from None
|
|
46
|
+
|
|
47
|
+
return app
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = create_app()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from fastapi.testclient import TestClient
|
|
2
|
+
|
|
3
|
+
from {{ package_name }}.main import create_app
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def client() -> TestClient:
|
|
7
|
+
return TestClient(create_app())
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_health() -> None:
|
|
11
|
+
response = client().get("/health")
|
|
12
|
+
assert response.status_code == 200
|
|
13
|
+
assert response.json() == {"status": "ok"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_items_start_empty() -> None:
|
|
17
|
+
assert client().get("/items").json() == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_create_then_fetch_item() -> None:
|
|
21
|
+
api = client()
|
|
22
|
+
created = api.post("/items", json={"name": "pen", "price": 1.5})
|
|
23
|
+
assert created.status_code == 201
|
|
24
|
+
body = created.json()
|
|
25
|
+
assert body["id"] == 1
|
|
26
|
+
assert body["name"] == "pen"
|
|
27
|
+
|
|
28
|
+
fetched = api.get(f"/items/{body['id']}")
|
|
29
|
+
assert fetched.status_code == 200
|
|
30
|
+
assert fetched.json() == body
|
|
31
|
+
assert api.get("/items").json() == [body]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_unknown_item_is_404() -> None:
|
|
35
|
+
assert client().get("/items/999").status_code == 404
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_validation_rejects_bad_payload() -> None:
|
|
39
|
+
api = client()
|
|
40
|
+
assert api.post("/items", json={"name": "", "price": 1}).status_code == 422
|
|
41
|
+
assert api.post("/items", json={"name": "ok", "price": -1}).status_code == 422
|