snapstack 1.0.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.
- pysnap/__init__.py +1 -0
- pysnap/_shared/Dockerfile.j2 +34 -0
- pysnap/_shared/ci.yml.j2 +53 -0
- pysnap/_shared/docker-compose.yml.j2 +46 -0
- pysnap/_shared/dockerignore.j2 +13 -0
- pysnap/_shared/env_example.j2 +24 -0
- pysnap/_shared/gitignore.j2 +15 -0
- pysnap/commands/__init__.py +1 -0
- pysnap/commands/add.py +171 -0
- pysnap/commands/create.py +136 -0
- pysnap/commands/templates_cmd.py +134 -0
- pysnap/commands/update.py +133 -0
- pysnap/community.py +113 -0
- pysnap/config.py +76 -0
- pysnap/generator.py +262 -0
- pysnap/main.py +65 -0
- pysnap/manifest.py +101 -0
- pysnap/plugins.py +123 -0
- pysnap/preview.py +131 -0
- pysnap/prompts.py +217 -0
- pysnap/registry.py +123 -0
- pysnap/templates/django/.dockerignore.j2 +15 -0
- pysnap/templates/django/.github/workflows/ci.yml.j2 +34 -0
- pysnap/templates/django/.gitignore.j2 +14 -0
- pysnap/templates/django/Dockerfile.j2 +14 -0
- pysnap/templates/django/README.md.j2 +36 -0
- pysnap/templates/django/apps/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/core/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/core/apps.py.j2 +6 -0
- pysnap/templates/django/apps/core/urls.py.j2 +7 -0
- pysnap/templates/django/apps/core/views.py.j2 +6 -0
- pysnap/templates/django/apps/users/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/users/apps.py.j2 +6 -0
- pysnap/templates/django/apps/users/models.py.j2 +14 -0
- pysnap/templates/django/apps/users/serializers.py.j2 +13 -0
- pysnap/templates/django/apps/users/urls.py.j2 +10 -0
- pysnap/templates/django/apps/users/views.py.j2 +22 -0
- pysnap/templates/django/config/__init__.py.j2 +0 -0
- pysnap/templates/django/config/asgi.py.j2 +9 -0
- pysnap/templates/django/config/settings.py.j2 +110 -0
- pysnap/templates/django/config/urls.py.j2 +12 -0
- pysnap/templates/django/config/wsgi.py.j2 +9 -0
- pysnap/templates/django/docker-compose.yml.j2 +29 -0
- pysnap/templates/django/manage.py.j2 +22 -0
- pysnap/templates/django/pyproject.toml.j2 +40 -0
- pysnap/templates/django/template.json +50 -0
- pysnap/templates/django/tests/__init__.py.j2 +1 -0
- pysnap/templates/django/tests/conftest.py.j2 +6 -0
- pysnap/templates/django/tests/test_health.py.j2 +9 -0
- pysnap/templates/fastapi/.dockerignore.j2 +8 -0
- pysnap/templates/fastapi/.github/workflows/ci.yml.j2 +46 -0
- pysnap/templates/fastapi/.gitignore.j2 +13 -0
- pysnap/templates/fastapi/Dockerfile.j2 +14 -0
- pysnap/templates/fastapi/README.md.j2 +57 -0
- pysnap/templates/fastapi/api/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/api/routes/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/api/routes/auth.py.j2 +18 -0
- pysnap/templates/fastapi/api/routes/health.py.j2 +8 -0
- pysnap/templates/fastapi/app/__init__.py.j2 +1 -0
- pysnap/templates/fastapi/core/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/core/config.py.j2 +26 -0
- pysnap/templates/fastapi/core/security.py.j2 +22 -0
- pysnap/templates/fastapi/db/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/db/base.py.j2 +5 -0
- pysnap/templates/fastapi/db/session.py.j2 +15 -0
- pysnap/templates/fastapi/docker-compose.yml.j2 +30 -0
- pysnap/templates/fastapi/main.py.j2 +27 -0
- pysnap/templates/fastapi/models/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/models/user.py.j2 +13 -0
- pysnap/templates/fastapi/pyproject.toml.j2 +48 -0
- pysnap/templates/fastapi/schemas/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/schemas/user.py.j2 +18 -0
- pysnap/templates/fastapi/template.json +53 -0
- pysnap/templates/fastapi/tests/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/tests/conftest.py.j2 +9 -0
- pysnap/templates/fastapi/tests/test_health.py.j2 +9 -0
- pysnap/templates/flask/.dockerignore.j2 +14 -0
- pysnap/templates/flask/.github/workflows/ci.yml.j2 +34 -0
- pysnap/templates/flask/.gitignore.j2 +13 -0
- pysnap/templates/flask/Dockerfile.j2 +14 -0
- pysnap/templates/flask/README.md.j2 +34 -0
- pysnap/templates/flask/app/__init__.py.j2 +30 -0
- pysnap/templates/flask/app/config.py.j2 +23 -0
- pysnap/templates/flask/app/extensions.py.j2 +9 -0
- pysnap/templates/flask/app/models/__init__.py.j2 +1 -0
- pysnap/templates/flask/app/models/user.py.j2 +16 -0
- pysnap/templates/flask/app/routes/__init__.py.j2 +1 -0
- pysnap/templates/flask/app/routes/auth.py.j2 +31 -0
- pysnap/templates/flask/app/routes/health.py.j2 +11 -0
- pysnap/templates/flask/docker-compose.yml.j2 +29 -0
- pysnap/templates/flask/pyproject.toml.j2 +39 -0
- pysnap/templates/flask/template.json +44 -0
- pysnap/templates/flask/tests/__init__.py.j2 +1 -0
- pysnap/templates/flask/tests/conftest.py.j2 +16 -0
- pysnap/templates/flask/tests/test_health.py.j2 +8 -0
- pysnap/templates/flask/wsgi.py.j2 +8 -0
- pysnap/validator.py +89 -0
- snapstack-1.0.0.dist-info/METADATA +267 -0
- snapstack-1.0.0.dist-info/RECORD +102 -0
- snapstack-1.0.0.dist-info/WHEEL +4 -0
- snapstack-1.0.0.dist-info/entry_points.txt +2 -0
- snapstack-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``pysnap update`` command -- update infrastructure files in a pysnap project.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import difflib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
|
|
15
|
+
from pysnap.manifest import read_manifest
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def update_command(
|
|
21
|
+
accept_all: Annotated[
|
|
22
|
+
bool,
|
|
23
|
+
typer.Option("--accept-all", help="Accept all updates without confirmation"),
|
|
24
|
+
] = False,
|
|
25
|
+
dry_run: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option("--dry-run", help="Show diffs without writing changes"),
|
|
28
|
+
] = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Update infrastructure files in a pysnap-generated project."""
|
|
31
|
+
project_path = Path.cwd()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
manifest = read_manifest(project_path)
|
|
35
|
+
except FileNotFoundError as exc:
|
|
36
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
template_name = manifest.get("template", "")
|
|
40
|
+
original_options = manifest.get("options", {})
|
|
41
|
+
infrastructure_files: list[str] = manifest.get("infrastructure_files", [])
|
|
42
|
+
|
|
43
|
+
if not infrastructure_files:
|
|
44
|
+
console.print("[green]No infrastructure files to update.[/green]")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Re-render infrastructure files from current template
|
|
48
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
49
|
+
from pysnap.generator import load_template_manifest
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
manifest_data = load_template_manifest(template_name)
|
|
53
|
+
except FileNotFoundError as exc:
|
|
54
|
+
console.print(f"[red]Template not found:[/red] {exc}")
|
|
55
|
+
raise typer.Exit(code=1)
|
|
56
|
+
|
|
57
|
+
template_dir = Path(__file__).parent.parent / "templates" / template_name
|
|
58
|
+
shared_dir = Path(__file__).parent.parent / "_shared"
|
|
59
|
+
search_paths = [str(template_dir)]
|
|
60
|
+
if shared_dir.is_dir():
|
|
61
|
+
search_paths.append(str(shared_dir))
|
|
62
|
+
|
|
63
|
+
env = Environment(
|
|
64
|
+
loader=FileSystemLoader(search_paths),
|
|
65
|
+
autoescape=select_autoescape([]),
|
|
66
|
+
keep_trailing_newline=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
context = {**original_options}
|
|
70
|
+
context["project_name_slug"] = original_options.get("project_name", "").replace("-", "_")
|
|
71
|
+
|
|
72
|
+
# Build reverse map: output_path -> template_name
|
|
73
|
+
all_files = manifest_data.get("files", {})
|
|
74
|
+
output_to_template: dict[str, str] = {}
|
|
75
|
+
for group_files in all_files.values():
|
|
76
|
+
for tmpl, out in group_files.items():
|
|
77
|
+
output_to_template[out] = tmpl
|
|
78
|
+
|
|
79
|
+
changed = 0
|
|
80
|
+
no_change = 0
|
|
81
|
+
|
|
82
|
+
for out_rel in infrastructure_files:
|
|
83
|
+
tmpl_name = output_to_template.get(out_rel)
|
|
84
|
+
if not tmpl_name:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
rendered = env.get_template(tmpl_name).render(**context)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
console.print(f"[yellow]Warning:[/yellow] Could not render {out_rel}: {exc}")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
current_path = project_path / out_rel
|
|
94
|
+
if current_path.exists():
|
|
95
|
+
current = current_path.read_text(encoding="utf-8")
|
|
96
|
+
else:
|
|
97
|
+
current = ""
|
|
98
|
+
|
|
99
|
+
if current == rendered:
|
|
100
|
+
no_change += 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Show diff
|
|
104
|
+
diff = list(difflib.unified_diff(
|
|
105
|
+
current.splitlines(keepends=True),
|
|
106
|
+
rendered.splitlines(keepends=True),
|
|
107
|
+
fromfile=f"current/{out_rel}",
|
|
108
|
+
tofile=f"updated/{out_rel}",
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
console.print(f"\n[bold cyan]{out_rel}[/bold cyan]")
|
|
112
|
+
console.print(Syntax("".join(diff), "diff", theme="monokai", line_numbers=False))
|
|
113
|
+
|
|
114
|
+
if dry_run:
|
|
115
|
+
changed += 1
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
should_write = accept_all
|
|
119
|
+
if not should_write:
|
|
120
|
+
import questionary
|
|
121
|
+
answer = questionary.confirm(f"Apply update to {out_rel}?", default=True).ask()
|
|
122
|
+
should_write = bool(answer)
|
|
123
|
+
|
|
124
|
+
if should_write:
|
|
125
|
+
current_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
current_path.write_text(rendered, encoding="utf-8")
|
|
127
|
+
console.print(f"[green] Updated[/green] {out_rel}")
|
|
128
|
+
changed += 1
|
|
129
|
+
|
|
130
|
+
if dry_run:
|
|
131
|
+
console.print(f"\n[dim]Dry run: {changed} file(s) would change, {no_change} unchanged.[/dim]")
|
|
132
|
+
else:
|
|
133
|
+
console.print(f"\n[green]Done.[/green] {changed} file(s) updated, {no_change} unchanged.")
|
pysnap/community.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Community template cloning and validation.
|
|
3
|
+
|
|
4
|
+
Handles ``pysnap use <url> <project_name>``:
|
|
5
|
+
1. Clone the repository (shallow) to a temp directory.
|
|
6
|
+
2. Validate that it contains a template.json manifest.
|
|
7
|
+
3. Delegate generation to the standard generator pipeline.
|
|
8
|
+
4. Clean up the clone.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clone_template(url: str, dest: Path) -> None:
|
|
22
|
+
"""Shallow-clone *url* into *dest*.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
url:
|
|
27
|
+
Git repository URL.
|
|
28
|
+
dest:
|
|
29
|
+
Target directory (must not already exist).
|
|
30
|
+
|
|
31
|
+
Raises
|
|
32
|
+
------
|
|
33
|
+
RuntimeError
|
|
34
|
+
If git is not installed or the clone command fails.
|
|
35
|
+
"""
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "clone", "--depth", "1", url, str(dest)],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"git clone failed for {url!r}:\n{result.stderr}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_template(path: Path) -> bool:
|
|
48
|
+
"""Return True if *path* is a valid pysnap template directory.
|
|
49
|
+
|
|
50
|
+
A valid template must contain a ``template.json`` with at least the
|
|
51
|
+
``name`` and ``files`` fields.
|
|
52
|
+
"""
|
|
53
|
+
manifest_path = path / "template.json"
|
|
54
|
+
if not manifest_path.exists():
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
58
|
+
return "name" in data and "files" in data
|
|
59
|
+
except Exception: # noqa: BLE001
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cleanup_clone(path: Path) -> None:
|
|
64
|
+
"""Remove the cloned template directory."""
|
|
65
|
+
if path.exists():
|
|
66
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def use_community_template(
|
|
70
|
+
template_url: str,
|
|
71
|
+
project_name: str,
|
|
72
|
+
options: dict,
|
|
73
|
+
output_dir: str,
|
|
74
|
+
console,
|
|
75
|
+
) -> Path | None:
|
|
76
|
+
"""Clone a community template and generate a project from it.
|
|
77
|
+
|
|
78
|
+
Returns the generated project path or None on failure.
|
|
79
|
+
"""
|
|
80
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="pysnap-clone-"))
|
|
81
|
+
clone_path = tmp_dir / "template"
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
console.print(f"[dim]Cloning {template_url}...[/dim]")
|
|
85
|
+
clone_template(template_url, clone_path)
|
|
86
|
+
|
|
87
|
+
if not validate_template(clone_path):
|
|
88
|
+
console.print(
|
|
89
|
+
"[red]Invalid template:[/red] The repository must contain a valid template.json."
|
|
90
|
+
)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
# Temporarily patch generator to use the cloned template directory
|
|
94
|
+
from pysnap import generator as gen_module
|
|
95
|
+
|
|
96
|
+
original_resolve = gen_module._resolve_template_dir
|
|
97
|
+
|
|
98
|
+
def _patched_resolve(framework: str) -> Path:
|
|
99
|
+
return clone_path
|
|
100
|
+
|
|
101
|
+
gen_module._resolve_template_dir = _patched_resolve # type: ignore[assignment]
|
|
102
|
+
try:
|
|
103
|
+
result = gen_module.generate_project(project_name, options, output_dir, console)
|
|
104
|
+
finally:
|
|
105
|
+
gen_module._resolve_template_dir = original_resolve # type: ignore[assignment]
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
except RuntimeError as exc:
|
|
110
|
+
console.print(f"[red]Clone failed:[/red] {exc}")
|
|
111
|
+
return None
|
|
112
|
+
finally:
|
|
113
|
+
cleanup_clone(tmp_dir)
|
pysnap/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysnap internal configuration constants.
|
|
3
|
+
|
|
4
|
+
This module is the single source of truth for all pysnap-wide defaults,
|
|
5
|
+
supported option sets, and external service URLs. Never import user-facing
|
|
6
|
+
constants from here -- use it only for internal logic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PYSNAP_VERSION",
|
|
13
|
+
"REGISTRY_URL",
|
|
14
|
+
"REGISTRY_CACHE_TTL_SECONDS",
|
|
15
|
+
"REGISTRY_CACHE_PATH",
|
|
16
|
+
"PLUGIN_ENTRY_POINT_GROUP",
|
|
17
|
+
"SUPPORTED_FRAMEWORKS",
|
|
18
|
+
"SUPPORTED_DATABASES",
|
|
19
|
+
"SUPPORTED_PACKAGE_MANAGERS",
|
|
20
|
+
"DEFAULT_OPTIONS",
|
|
21
|
+
"MANIFEST_SCHEMA_VERSION",
|
|
22
|
+
"MANIFEST_FILENAME",
|
|
23
|
+
"INFRASTRUCTURE_FILE_GROUPS",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
PYSNAP_VERSION: str = "1.0.0"
|
|
29
|
+
|
|
30
|
+
# Community template registry
|
|
31
|
+
REGISTRY_URL: str = (
|
|
32
|
+
"https://raw.githubusercontent.com/pysnap-dev/pysnap/main/registry/templates.json"
|
|
33
|
+
)
|
|
34
|
+
REGISTRY_CACHE_TTL_SECONDS: int = 3600 # 1 hour
|
|
35
|
+
REGISTRY_CACHE_PATH: Path = Path.home() / ".cache" / "pysnap" / "registry.json"
|
|
36
|
+
|
|
37
|
+
# Plugin discovery
|
|
38
|
+
PLUGIN_ENTRY_POINT_GROUP: str = "pysnap.plugins"
|
|
39
|
+
|
|
40
|
+
# Built-in framework names and display names
|
|
41
|
+
SUPPORTED_FRAMEWORKS: dict[str, str] = {
|
|
42
|
+
"fastapi": "FastAPI",
|
|
43
|
+
"django": "Django",
|
|
44
|
+
"flask": "Flask",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Built-in database names and display names
|
|
48
|
+
SUPPORTED_DATABASES: dict[str, str] = {
|
|
49
|
+
"sqlite": "SQLite",
|
|
50
|
+
"postgresql": "PostgreSQL",
|
|
51
|
+
"none": "None",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Supported package managers
|
|
55
|
+
SUPPORTED_PACKAGE_MANAGERS: list[str] = ["uv", "pip", "poetry"]
|
|
56
|
+
|
|
57
|
+
# Default option values (used for non-interactive mode when flag is omitted)
|
|
58
|
+
DEFAULT_OPTIONS: dict = {
|
|
59
|
+
"framework": "fastapi",
|
|
60
|
+
"database": "sqlite",
|
|
61
|
+
"include_auth": False,
|
|
62
|
+
"include_docker": True,
|
|
63
|
+
"include_ci": True,
|
|
64
|
+
"package_manager": "uv",
|
|
65
|
+
"include_tests": True,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# .pysnap.json manifest
|
|
69
|
+
MANIFEST_SCHEMA_VERSION: str = "1.0"
|
|
70
|
+
MANIFEST_FILENAME: str = ".pysnap.json"
|
|
71
|
+
|
|
72
|
+
# Template condition groups that map to infrastructure files eligible for `pysnap update`
|
|
73
|
+
INFRASTRUCTURE_FILE_GROUPS: list[str] = [
|
|
74
|
+
"when_docker",
|
|
75
|
+
"when_ci",
|
|
76
|
+
]
|
pysnap/generator.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project generation engine.
|
|
3
|
+
|
|
4
|
+
Renders Jinja2 templates into the target project directory using a
|
|
5
|
+
manifest-driven file mapping. The mapping comes from each framework
|
|
6
|
+
template's ``template.json`` rather than being hard-coded, so new
|
|
7
|
+
frameworks can be added without touching this module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
23
|
+
|
|
24
|
+
from pysnap.config import (
|
|
25
|
+
INFRASTRUCTURE_FILE_GROUPS,
|
|
26
|
+
PYSNAP_VERSION,
|
|
27
|
+
)
|
|
28
|
+
from pysnap.manifest import write_manifest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Public API
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_project(
|
|
37
|
+
project_name: str,
|
|
38
|
+
options: dict[str, Any],
|
|
39
|
+
output_dir: str,
|
|
40
|
+
console: Console,
|
|
41
|
+
) -> Path | None:
|
|
42
|
+
"""Scaffold a new project from a template into *output_dir/project_name*.
|
|
43
|
+
|
|
44
|
+
Returns the generated project path on success, or ``None`` if the
|
|
45
|
+
directory already exists.
|
|
46
|
+
"""
|
|
47
|
+
project_path = Path(output_dir) / project_name
|
|
48
|
+
|
|
49
|
+
if project_path.exists():
|
|
50
|
+
console.print(
|
|
51
|
+
f"[red]Error:[/red] Directory [bold]{project_path}[/bold] already exists."
|
|
52
|
+
)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# Register cleanup so Ctrl-C leaves no partial project behind.
|
|
56
|
+
_register_cleanup(project_path)
|
|
57
|
+
|
|
58
|
+
template_dir = _resolve_template_dir(options["framework"])
|
|
59
|
+
manifest_data = _load_manifest(template_dir)
|
|
60
|
+
template_map = _build_template_map(manifest_data, options)
|
|
61
|
+
infrastructure_files = _collect_infrastructure_files(manifest_data, options)
|
|
62
|
+
|
|
63
|
+
shared_dir = Path(__file__).parent / "_shared"
|
|
64
|
+
search_paths = [str(template_dir)]
|
|
65
|
+
if shared_dir.is_dir():
|
|
66
|
+
search_paths.append(str(shared_dir))
|
|
67
|
+
|
|
68
|
+
env = Environment(
|
|
69
|
+
loader=FileSystemLoader(search_paths),
|
|
70
|
+
autoescape=select_autoescape([]),
|
|
71
|
+
keep_trailing_newline=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
context = {
|
|
75
|
+
**options,
|
|
76
|
+
"project_name_slug": project_name.replace("-", "_").replace(" ", "_"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with Progress(
|
|
81
|
+
SpinnerColumn(),
|
|
82
|
+
TextColumn("[progress.description]{task.description}"),
|
|
83
|
+
console=console,
|
|
84
|
+
transient=True,
|
|
85
|
+
) as progress:
|
|
86
|
+
task = progress.add_task("[green]Generating project...", total=len(template_map))
|
|
87
|
+
for tmpl_name, out_path in template_map.items():
|
|
88
|
+
full_out = project_path / out_path
|
|
89
|
+
full_out.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
try:
|
|
91
|
+
content = env.get_template(tmpl_name).render(**context)
|
|
92
|
+
full_out.write_text(content, encoding="utf-8")
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
console.print(
|
|
95
|
+
f"[yellow]Warning:[/yellow] Could not generate {out_path}: {exc}"
|
|
96
|
+
)
|
|
97
|
+
progress.advance(task)
|
|
98
|
+
|
|
99
|
+
_write_project_manifest(
|
|
100
|
+
project_path=project_path,
|
|
101
|
+
manifest_data=manifest_data,
|
|
102
|
+
options=options,
|
|
103
|
+
infrastructure_files=infrastructure_files,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_print_success(console, project_name, options)
|
|
107
|
+
|
|
108
|
+
# Cleanup no longer needed -- generation succeeded.
|
|
109
|
+
_cancel_cleanup()
|
|
110
|
+
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
# Cleanup fires via atexit; we just exit cleanly.
|
|
113
|
+
console.print("\n[yellow]Cancelled -- cleaning up partial project...[/yellow]")
|
|
114
|
+
sys.exit(130)
|
|
115
|
+
|
|
116
|
+
return project_path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_template_manifest(framework: str) -> dict[str, Any]:
|
|
120
|
+
"""Load the template.json manifest for *framework*."""
|
|
121
|
+
return _load_manifest(_resolve_template_dir(framework))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_template_map(manifest_data: dict[str, Any], options: dict[str, Any]) -> dict[str, str]:
|
|
125
|
+
"""Public wrapper around the internal template-map builder."""
|
|
126
|
+
return _build_template_map(manifest_data, options)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Internal helpers
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
_cleanup_path: Path | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _register_cleanup(project_path: Path) -> None:
|
|
137
|
+
global _cleanup_path
|
|
138
|
+
_cleanup_path = project_path
|
|
139
|
+
atexit.register(_atexit_cleanup)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cancel_cleanup() -> None:
|
|
143
|
+
global _cleanup_path
|
|
144
|
+
_cleanup_path = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _atexit_cleanup() -> None:
|
|
148
|
+
global _cleanup_path
|
|
149
|
+
if _cleanup_path is not None and _cleanup_path.exists():
|
|
150
|
+
shutil.rmtree(_cleanup_path, ignore_errors=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_template_dir(framework: str) -> Path:
|
|
154
|
+
"""Return the absolute path to the Jinja2 template directory for *framework*."""
|
|
155
|
+
here = Path(__file__).parent
|
|
156
|
+
template_dir = here / "templates" / framework
|
|
157
|
+
if not template_dir.is_dir():
|
|
158
|
+
raise FileNotFoundError(
|
|
159
|
+
f"No template directory found for framework '{framework}' "
|
|
160
|
+
f"(expected {template_dir})."
|
|
161
|
+
)
|
|
162
|
+
return template_dir
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load_manifest(template_dir: Path) -> dict[str, Any]:
|
|
166
|
+
"""Parse template.json from *template_dir*."""
|
|
167
|
+
manifest_path = template_dir / "template.json"
|
|
168
|
+
if not manifest_path.exists():
|
|
169
|
+
raise FileNotFoundError(
|
|
170
|
+
f"Template manifest not found: {manifest_path}. "
|
|
171
|
+
"Check that the template directory contains a template.json."
|
|
172
|
+
)
|
|
173
|
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_template_map(
|
|
177
|
+
manifest_data: dict[str, Any],
|
|
178
|
+
options: dict[str, Any],
|
|
179
|
+
) -> dict[str, str]:
|
|
180
|
+
"""Evaluate the manifest's condition groups and return the active file mapping."""
|
|
181
|
+
files = manifest_data.get("files", {})
|
|
182
|
+
mapping: dict[str, str] = {}
|
|
183
|
+
|
|
184
|
+
for group, group_files in files.items():
|
|
185
|
+
if _group_active(group, options):
|
|
186
|
+
mapping.update(group_files)
|
|
187
|
+
|
|
188
|
+
return mapping
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _group_active(group: str, options: dict[str, Any]) -> bool:
|
|
192
|
+
"""Return True if a condition group should be included given *options*."""
|
|
193
|
+
db_selected = options.get("database", "none") != "none"
|
|
194
|
+
auth_selected = bool(options.get("include_auth"))
|
|
195
|
+
|
|
196
|
+
conditions: dict[str, bool] = {
|
|
197
|
+
"always": True,
|
|
198
|
+
"when_database": db_selected,
|
|
199
|
+
"when_auth": auth_selected,
|
|
200
|
+
"when_auth_and_database": auth_selected and db_selected,
|
|
201
|
+
"when_docker": bool(options.get("include_docker")),
|
|
202
|
+
"when_tests": bool(options.get("include_tests")),
|
|
203
|
+
"when_ci": bool(options.get("include_ci")),
|
|
204
|
+
}
|
|
205
|
+
return conditions.get(group, False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _collect_infrastructure_files(
|
|
209
|
+
manifest_data: dict[str, Any],
|
|
210
|
+
options: dict[str, Any],
|
|
211
|
+
) -> list[str]:
|
|
212
|
+
"""Return relative output paths of infrastructure-only template files."""
|
|
213
|
+
files = manifest_data.get("files", {})
|
|
214
|
+
infra: list[str] = []
|
|
215
|
+
for group in INFRASTRUCTURE_FILE_GROUPS:
|
|
216
|
+
if _group_active(group, options):
|
|
217
|
+
infra.extend(files.get(group, {}).values())
|
|
218
|
+
return infra
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _write_project_manifest(
|
|
222
|
+
project_path: Path,
|
|
223
|
+
manifest_data: dict[str, Any],
|
|
224
|
+
options: dict[str, Any],
|
|
225
|
+
infrastructure_files: list[str],
|
|
226
|
+
) -> None:
|
|
227
|
+
write_manifest(
|
|
228
|
+
project_path=project_path,
|
|
229
|
+
template=manifest_data["name"],
|
|
230
|
+
template_version=manifest_data.get("version", "unknown"),
|
|
231
|
+
options=options,
|
|
232
|
+
infrastructure_files=infrastructure_files,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _print_success(console: Console, project_name: str, options: dict[str, Any]) -> None:
|
|
237
|
+
pm = options["package_manager"]
|
|
238
|
+
install_cmd = {
|
|
239
|
+
"uv": "uv sync",
|
|
240
|
+
"pip": 'pip install -e ".[dev]"',
|
|
241
|
+
"poetry": "poetry install",
|
|
242
|
+
}.get(pm, 'pip install -e ".[dev]"')
|
|
243
|
+
|
|
244
|
+
framework = options["framework"]
|
|
245
|
+
run_cmd = {
|
|
246
|
+
"fastapi": "uvicorn app.main:app --reload",
|
|
247
|
+
"django": "python manage.py runserver",
|
|
248
|
+
"flask": "flask --app app run --debug",
|
|
249
|
+
}.get(framework, "python -m app")
|
|
250
|
+
|
|
251
|
+
console.print()
|
|
252
|
+
console.print(
|
|
253
|
+
Panel(
|
|
254
|
+
f"[bold green]Project [cyan]{project_name}[/cyan] created successfully![/bold green]\n\n"
|
|
255
|
+
f"[bold]Next steps:[/bold]\n"
|
|
256
|
+
f" [cyan]cd {project_name}[/cyan]\n"
|
|
257
|
+
f" [cyan]{install_cmd}[/cyan]\n"
|
|
258
|
+
f" [cyan]{run_cmd}[/cyan]\n\n"
|
|
259
|
+
f"[dim]Run [white]pysnap update[/white] later to pull in template improvements.[/dim]",
|
|
260
|
+
border_style="green",
|
|
261
|
+
)
|
|
262
|
+
)
|
pysnap/main.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snapstack -- scaffold Python backend projects in seconds.
|
|
3
|
+
|
|
4
|
+
Entry point for the Typer CLI application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from pysnap import __version__
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="snapstack",
|
|
18
|
+
help="The create-next-app experience for Python backend projects.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
pretty_exceptions_enable=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_callback(value: bool) -> None:
|
|
27
|
+
if value:
|
|
28
|
+
console.print(f"snapstack {__version__}")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def main(
|
|
34
|
+
version: Optional[bool] = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--version",
|
|
37
|
+
"-v",
|
|
38
|
+
help="Show snapstack version and exit.",
|
|
39
|
+
callback=_version_callback,
|
|
40
|
+
is_eager=True,
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""snapstack -- scaffold Python backend projects in seconds."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Commands
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
from pysnap.commands.create import create_command # noqa: E402
|
|
51
|
+
|
|
52
|
+
app.command(name="create", help="Scaffold a new Python project.")(create_command)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Subcommand groups (registered in their own modules)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
from pysnap.commands.templates_cmd import templates_app # noqa: E402
|
|
60
|
+
from pysnap.commands.add import add_command # noqa: E402
|
|
61
|
+
from pysnap.commands.update import update_command # noqa: E402
|
|
62
|
+
|
|
63
|
+
app.add_typer(templates_app, name="templates")
|
|
64
|
+
app.command(name="add", help="Add boilerplate to an existing project.")(add_command)
|
|
65
|
+
app.command(name="update", help="Update infrastructure files in a pysnap project.")(update_command)
|