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.
Files changed (72) hide show
  1. newstack/__init__.py +4 -0
  2. newstack/__main__.py +8 -0
  3. newstack/cli.py +387 -0
  4. newstack/data/stacks/fastapi/stack.json +42 -0
  5. newstack/data/stacks/fastapi/template/.github/workflows/ci.yml +30 -0
  6. newstack/data/stacks/fastapi/template/.gitignore +11 -0
  7. newstack/data/stacks/fastapi/template/Dockerfile +13 -0
  8. newstack/data/stacks/fastapi/template/LICENSE +21 -0
  9. newstack/data/stacks/fastapi/template/README.md +30 -0
  10. newstack/data/stacks/fastapi/template/pyproject.toml +31 -0
  11. newstack/data/stacks/fastapi/template/src/{{package_name}}/__init__.py +4 -0
  12. newstack/data/stacks/fastapi/template/src/{{package_name}}/main.py +50 -0
  13. newstack/data/stacks/fastapi/template/tests/test_api.py +41 -0
  14. newstack/data/stacks/node-express-ts/stack.json +14 -0
  15. newstack/data/stacks/node-express-ts/template/.github/workflows/ci.yml +28 -0
  16. newstack/data/stacks/node-express-ts/template/.gitignore +6 -0
  17. newstack/data/stacks/node-express-ts/template/LICENSE +21 -0
  18. newstack/data/stacks/node-express-ts/template/README.md +24 -0
  19. newstack/data/stacks/node-express-ts/template/package.json +26 -0
  20. newstack/data/stacks/node-express-ts/template/src/app.ts +35 -0
  21. newstack/data/stacks/node-express-ts/template/src/server.ts +7 -0
  22. newstack/data/stacks/node-express-ts/template/tests/app.test.ts +32 -0
  23. newstack/data/stacks/node-express-ts/template/tsconfig.json +14 -0
  24. newstack/data/stacks/python-cli/stack.json +50 -0
  25. newstack/data/stacks/python-cli/template/.github/workflows/ci.yml +32 -0
  26. newstack/data/stacks/python-cli/template/.gitignore +11 -0
  27. newstack/data/stacks/python-cli/template/LICENSE +21 -0
  28. newstack/data/stacks/python-cli/template/README.md +35 -0
  29. newstack/data/stacks/python-cli/template/pyproject.toml +32 -0
  30. newstack/data/stacks/python-cli/template/src/{{package_name}}/__init__.py +4 -0
  31. newstack/data/stacks/python-cli/template/src/{{package_name}}/__main__.py +6 -0
  32. newstack/data/stacks/python-cli/template/src/{{package_name}}/cli.py +30 -0
  33. newstack/data/stacks/python-cli/template/tests/test_cli.py +20 -0
  34. newstack/data/stacks/python-lib/stack.json +42 -0
  35. newstack/data/stacks/python-lib/template/.github/workflows/ci.yml +32 -0
  36. newstack/data/stacks/python-lib/template/.gitignore +11 -0
  37. newstack/data/stacks/python-lib/template/LICENSE +21 -0
  38. newstack/data/stacks/python-lib/template/README.md +28 -0
  39. newstack/data/stacks/python-lib/template/pyproject.toml +34 -0
  40. newstack/data/stacks/python-lib/template/src/{{package_name}}/__init__.py +6 -0
  41. newstack/data/stacks/python-lib/template/src/{{package_name}}/core.py +23 -0
  42. newstack/data/stacks/python-lib/template/tests/test_core.py +31 -0
  43. newstack/data/stacks/react-vite-ts/stack.json +15 -0
  44. newstack/data/stacks/react-vite-ts/template/.github/workflows/ci.yml +29 -0
  45. newstack/data/stacks/react-vite-ts/template/.gitignore +7 -0
  46. newstack/data/stacks/react-vite-ts/template/LICENSE +21 -0
  47. newstack/data/stacks/react-vite-ts/template/README.md +25 -0
  48. newstack/data/stacks/react-vite-ts/template/index.html +12 -0
  49. newstack/data/stacks/react-vite-ts/template/package.json +28 -0
  50. newstack/data/stacks/react-vite-ts/template/src/App.test.tsx +20 -0
  51. newstack/data/stacks/react-vite-ts/template/src/App.tsx +15 -0
  52. newstack/data/stacks/react-vite-ts/template/src/main.tsx +16 -0
  53. newstack/data/stacks/react-vite-ts/template/src/styles.css +26 -0
  54. newstack/data/stacks/react-vite-ts/template/tsconfig.json +16 -0
  55. newstack/data/stacks/react-vite-ts/template/vite.config.ts +10 -0
  56. newstack/data/stacks/static-site/stack.json +14 -0
  57. newstack/data/stacks/static-site/template/.github/workflows/deploy-pages.yml +30 -0
  58. newstack/data/stacks/static-site/template/.gitignore +2 -0
  59. newstack/data/stacks/static-site/template/LICENSE +21 -0
  60. newstack/data/stacks/static-site/template/README.md +21 -0
  61. newstack/data/stacks/static-site/template/index.html +34 -0
  62. newstack/data/stacks/static-site/template/main.js +15 -0
  63. newstack/data/stacks/static-site/template/styles.css +74 -0
  64. newstack/data/stacks/static-site/template/tests/site.test.mjs +22 -0
  65. newstack/registry.py +137 -0
  66. newstack/render.py +163 -0
  67. newstack/verify.py +133 -0
  68. newstack-0.1.0.dist-info/METADATA +197 -0
  69. newstack-0.1.0.dist-info/RECORD +72 -0
  70. newstack-0.1.0.dist-info/WHEEL +4 -0
  71. newstack-0.1.0.dist-info/entry_points.txt +2 -0
  72. newstack-0.1.0.dist-info/licenses/LICENSE +21 -0
newstack/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """newstack — scaffold a new project on any popular stack, proven green in CI."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
newstack/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python -m newstack`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .DS_Store
@@ -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,4 @@
1
+ """{{ title }} — {{ description }}"""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -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