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.
Files changed (102) hide show
  1. pysnap/__init__.py +1 -0
  2. pysnap/_shared/Dockerfile.j2 +34 -0
  3. pysnap/_shared/ci.yml.j2 +53 -0
  4. pysnap/_shared/docker-compose.yml.j2 +46 -0
  5. pysnap/_shared/dockerignore.j2 +13 -0
  6. pysnap/_shared/env_example.j2 +24 -0
  7. pysnap/_shared/gitignore.j2 +15 -0
  8. pysnap/commands/__init__.py +1 -0
  9. pysnap/commands/add.py +171 -0
  10. pysnap/commands/create.py +136 -0
  11. pysnap/commands/templates_cmd.py +134 -0
  12. pysnap/commands/update.py +133 -0
  13. pysnap/community.py +113 -0
  14. pysnap/config.py +76 -0
  15. pysnap/generator.py +262 -0
  16. pysnap/main.py +65 -0
  17. pysnap/manifest.py +101 -0
  18. pysnap/plugins.py +123 -0
  19. pysnap/preview.py +131 -0
  20. pysnap/prompts.py +217 -0
  21. pysnap/registry.py +123 -0
  22. pysnap/templates/django/.dockerignore.j2 +15 -0
  23. pysnap/templates/django/.github/workflows/ci.yml.j2 +34 -0
  24. pysnap/templates/django/.gitignore.j2 +14 -0
  25. pysnap/templates/django/Dockerfile.j2 +14 -0
  26. pysnap/templates/django/README.md.j2 +36 -0
  27. pysnap/templates/django/apps/__init__.py.j2 +0 -0
  28. pysnap/templates/django/apps/core/__init__.py.j2 +0 -0
  29. pysnap/templates/django/apps/core/apps.py.j2 +6 -0
  30. pysnap/templates/django/apps/core/urls.py.j2 +7 -0
  31. pysnap/templates/django/apps/core/views.py.j2 +6 -0
  32. pysnap/templates/django/apps/users/__init__.py.j2 +0 -0
  33. pysnap/templates/django/apps/users/apps.py.j2 +6 -0
  34. pysnap/templates/django/apps/users/models.py.j2 +14 -0
  35. pysnap/templates/django/apps/users/serializers.py.j2 +13 -0
  36. pysnap/templates/django/apps/users/urls.py.j2 +10 -0
  37. pysnap/templates/django/apps/users/views.py.j2 +22 -0
  38. pysnap/templates/django/config/__init__.py.j2 +0 -0
  39. pysnap/templates/django/config/asgi.py.j2 +9 -0
  40. pysnap/templates/django/config/settings.py.j2 +110 -0
  41. pysnap/templates/django/config/urls.py.j2 +12 -0
  42. pysnap/templates/django/config/wsgi.py.j2 +9 -0
  43. pysnap/templates/django/docker-compose.yml.j2 +29 -0
  44. pysnap/templates/django/manage.py.j2 +22 -0
  45. pysnap/templates/django/pyproject.toml.j2 +40 -0
  46. pysnap/templates/django/template.json +50 -0
  47. pysnap/templates/django/tests/__init__.py.j2 +1 -0
  48. pysnap/templates/django/tests/conftest.py.j2 +6 -0
  49. pysnap/templates/django/tests/test_health.py.j2 +9 -0
  50. pysnap/templates/fastapi/.dockerignore.j2 +8 -0
  51. pysnap/templates/fastapi/.github/workflows/ci.yml.j2 +46 -0
  52. pysnap/templates/fastapi/.gitignore.j2 +13 -0
  53. pysnap/templates/fastapi/Dockerfile.j2 +14 -0
  54. pysnap/templates/fastapi/README.md.j2 +57 -0
  55. pysnap/templates/fastapi/api/__init__.py.j2 +0 -0
  56. pysnap/templates/fastapi/api/routes/__init__.py.j2 +0 -0
  57. pysnap/templates/fastapi/api/routes/auth.py.j2 +18 -0
  58. pysnap/templates/fastapi/api/routes/health.py.j2 +8 -0
  59. pysnap/templates/fastapi/app/__init__.py.j2 +1 -0
  60. pysnap/templates/fastapi/core/__init__.py.j2 +0 -0
  61. pysnap/templates/fastapi/core/config.py.j2 +26 -0
  62. pysnap/templates/fastapi/core/security.py.j2 +22 -0
  63. pysnap/templates/fastapi/db/__init__.py.j2 +0 -0
  64. pysnap/templates/fastapi/db/base.py.j2 +5 -0
  65. pysnap/templates/fastapi/db/session.py.j2 +15 -0
  66. pysnap/templates/fastapi/docker-compose.yml.j2 +30 -0
  67. pysnap/templates/fastapi/main.py.j2 +27 -0
  68. pysnap/templates/fastapi/models/__init__.py.j2 +0 -0
  69. pysnap/templates/fastapi/models/user.py.j2 +13 -0
  70. pysnap/templates/fastapi/pyproject.toml.j2 +48 -0
  71. pysnap/templates/fastapi/schemas/__init__.py.j2 +0 -0
  72. pysnap/templates/fastapi/schemas/user.py.j2 +18 -0
  73. pysnap/templates/fastapi/template.json +53 -0
  74. pysnap/templates/fastapi/tests/__init__.py.j2 +0 -0
  75. pysnap/templates/fastapi/tests/conftest.py.j2 +9 -0
  76. pysnap/templates/fastapi/tests/test_health.py.j2 +9 -0
  77. pysnap/templates/flask/.dockerignore.j2 +14 -0
  78. pysnap/templates/flask/.github/workflows/ci.yml.j2 +34 -0
  79. pysnap/templates/flask/.gitignore.j2 +13 -0
  80. pysnap/templates/flask/Dockerfile.j2 +14 -0
  81. pysnap/templates/flask/README.md.j2 +34 -0
  82. pysnap/templates/flask/app/__init__.py.j2 +30 -0
  83. pysnap/templates/flask/app/config.py.j2 +23 -0
  84. pysnap/templates/flask/app/extensions.py.j2 +9 -0
  85. pysnap/templates/flask/app/models/__init__.py.j2 +1 -0
  86. pysnap/templates/flask/app/models/user.py.j2 +16 -0
  87. pysnap/templates/flask/app/routes/__init__.py.j2 +1 -0
  88. pysnap/templates/flask/app/routes/auth.py.j2 +31 -0
  89. pysnap/templates/flask/app/routes/health.py.j2 +11 -0
  90. pysnap/templates/flask/docker-compose.yml.j2 +29 -0
  91. pysnap/templates/flask/pyproject.toml.j2 +39 -0
  92. pysnap/templates/flask/template.json +44 -0
  93. pysnap/templates/flask/tests/__init__.py.j2 +1 -0
  94. pysnap/templates/flask/tests/conftest.py.j2 +16 -0
  95. pysnap/templates/flask/tests/test_health.py.j2 +8 -0
  96. pysnap/templates/flask/wsgi.py.j2 +8 -0
  97. pysnap/validator.py +89 -0
  98. snapstack-1.0.0.dist-info/METADATA +267 -0
  99. snapstack-1.0.0.dist-info/RECORD +102 -0
  100. snapstack-1.0.0.dist-info/WHEEL +4 -0
  101. snapstack-1.0.0.dist-info/entry_points.txt +2 -0
  102. 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)