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
pysnap/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,34 @@
1
+ {% set python_ver = "3.11" %}
2
+ # syntax=docker/dockerfile:1
3
+ FROM python:{{ python_ver }}-slim
4
+
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ curl \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Install Python dependencies
13
+ COPY pyproject.toml .
14
+ {% if package_manager == "uv" %}
15
+ RUN pip install --no-cache-dir uv && uv sync --no-dev
16
+ {% elif package_manager == "poetry" %}
17
+ RUN pip install --no-cache-dir poetry && poetry install --only=main
18
+ {% else %}
19
+ RUN pip install --no-cache-dir -e .
20
+ {% endif %}
21
+
22
+ COPY . .
23
+
24
+ {% if framework == "fastapi" %}
25
+ EXPOSE 8000
26
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
27
+ {% elif framework == "django" %}
28
+ EXPOSE 8000
29
+ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
30
+ {% elif framework == "flask" %}
31
+ EXPOSE 5000
32
+ ENV FLASK_APP=app
33
+ CMD ["flask", "run", "--host=0.0.0.0"]
34
+ {% endif %}
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint-and-test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+ {% if package_manager == "uv" %}
21
+ - name: Install uv
22
+ run: pip install uv
23
+
24
+ - name: Install dependencies
25
+ run: uv sync
26
+
27
+ - name: Lint
28
+ run: uv run ruff check .
29
+
30
+ - name: Test
31
+ run: uv run pytest
32
+ {% elif package_manager == "poetry" %}
33
+ - name: Install Poetry
34
+ run: pip install poetry
35
+
36
+ - name: Install dependencies
37
+ run: poetry install
38
+
39
+ - name: Lint
40
+ run: poetry run ruff check .
41
+
42
+ - name: Test
43
+ run: poetry run pytest
44
+ {% else %}
45
+ - name: Install dependencies
46
+ run: pip install -e ".[dev]"
47
+
48
+ - name: Lint
49
+ run: ruff check .
50
+
51
+ - name: Test
52
+ run: pytest
53
+ {% endif %}
@@ -0,0 +1,46 @@
1
+ services:
2
+ app:
3
+ build: .
4
+ {% if framework == "fastapi" %}
5
+ ports:
6
+ - "8000:8000"
7
+ {% elif framework == "django" %}
8
+ ports:
9
+ - "8000:8000"
10
+ {% elif framework == "flask" %}
11
+ ports:
12
+ - "5000:5000"
13
+ {% endif %}
14
+ env_file:
15
+ - .env
16
+ depends_on:
17
+ {% if database == "postgresql" %}
18
+ db:
19
+ condition: service_healthy
20
+ {% endif %}
21
+ develop:
22
+ watch:
23
+ - action: sync
24
+ path: .
25
+ target: /app
26
+ {% if database == "postgresql" %}
27
+
28
+ db:
29
+ image: postgres:16-alpine
30
+ environment:
31
+ POSTGRES_USER: ${POSTGRES_USER:-user}
32
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
33
+ POSTGRES_DB: ${POSTGRES_DB:-{{ project_name_slug }}}
34
+ ports:
35
+ - "5432:5432"
36
+ volumes:
37
+ - pgdata:/var/lib/postgresql/data
38
+ healthcheck:
39
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-user}"]
40
+ interval: 5s
41
+ timeout: 5s
42
+ retries: 5
43
+
44
+ volumes:
45
+ pgdata:
46
+ {% endif %}
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ *.log
12
+ .DS_Store
13
+ Thumbs.db
@@ -0,0 +1,24 @@
1
+ # {{ project_name }} environment variables
2
+ # Copy this file to .env and fill in your values
3
+
4
+ # Application
5
+ PROJECT_NAME="{{ project_name }}"
6
+ VERSION="0.1.0"
7
+ DESCRIPTION="Generated by pysnap"
8
+ API_PREFIX="/api"
9
+ DEBUG=false
10
+ ALLOWED_ORIGINS='["http://localhost:3000","http://localhost:8000"]'
11
+
12
+ {% if include_auth %}
13
+ # Security
14
+ SECRET_KEY="change-me-generate-a-long-random-string"
15
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
16
+ ALGORITHM="HS256"
17
+ {% endif %}
18
+ {% if database == "postgresql" %}
19
+ # Database
20
+ DATABASE_URL="postgresql+asyncpg://user:password@localhost:5432/{{ project_name_slug }}"
21
+ {% elif database == "sqlite" %}
22
+ # Database
23
+ DATABASE_URL="sqlite+aiosqlite:///./{{ project_name_slug }}.db"
24
+ {% endif %}
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ dist/
9
+ *.egg-info/
10
+ .pytest_cache/
11
+ .coverage
12
+ htmlcov/
13
+ *.log
14
+ .DS_Store
15
+ Thumbs.db
@@ -0,0 +1 @@
1
+ """Commands subpackage for pysnap."""
pysnap/commands/add.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ ``pysnap add`` command -- add boilerplate to an existing project.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from pysnap.generator import build_template_map, load_template_manifest
14
+
15
+ console = Console()
16
+
17
+ SUPPORTED_COMPONENTS = ["docker", "ci", "auth", "tests"]
18
+
19
+ COMPONENT_TO_GROUP = {
20
+ "docker": "when_docker",
21
+ "ci": "when_ci",
22
+ "auth": "when_auth",
23
+ "tests": "when_tests",
24
+ }
25
+
26
+
27
+ def add_command(
28
+ component: Annotated[
29
+ str,
30
+ typer.Argument(help=f"Component to add: {', '.join(SUPPORTED_COMPONENTS)}"),
31
+ ],
32
+ framework: Annotated[
33
+ Optional[str],
34
+ typer.Option("--framework", "-f", help="Framework (auto-detected from pyproject.toml)"),
35
+ ] = None,
36
+ db: Annotated[
37
+ Optional[str],
38
+ typer.Option("--db", "-d", help="Database (auto-detected from pyproject.toml)"),
39
+ ] = None,
40
+ pm: Annotated[
41
+ Optional[str],
42
+ typer.Option("--pm", help="Package manager"),
43
+ ] = None,
44
+ force: Annotated[
45
+ bool,
46
+ typer.Option("--force", help="Overwrite existing files without asking"),
47
+ ] = False,
48
+ ) -> None:
49
+ """Add boilerplate to an existing Python project."""
50
+ if component not in SUPPORTED_COMPONENTS:
51
+ console.print(
52
+ f"[red]Unknown component:[/red] {component!r}. "
53
+ f"Choose from: {', '.join(SUPPORTED_COMPONENTS)}"
54
+ )
55
+ raise typer.Exit(code=1)
56
+
57
+ project_path = Path.cwd()
58
+
59
+ # Auto-detect framework from pyproject.toml if not provided
60
+ detected_framework = framework or _detect_framework(project_path)
61
+ if not detected_framework:
62
+ console.print(
63
+ "[red]Cannot detect framework.[/red] "
64
+ "Run from your project root or specify --framework."
65
+ )
66
+ raise typer.Exit(code=1)
67
+
68
+ detected_db = db or _detect_database(project_path)
69
+ detected_pm = pm or _detect_package_manager(project_path)
70
+
71
+ options = {
72
+ "framework": detected_framework,
73
+ "database": detected_db or "none",
74
+ "include_auth": component == "auth",
75
+ "include_docker": component == "docker",
76
+ "include_ci": component == "ci",
77
+ "include_tests": component == "tests",
78
+ "package_manager": detected_pm or "pip",
79
+ "project_name": project_path.name,
80
+ "project_name_slug": project_path.name.replace("-", "_"),
81
+ }
82
+
83
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
84
+ from pysnap.config import SUPPORTED_FRAMEWORKS
85
+
86
+ template_dir = _find_template_dir(detected_framework)
87
+ if not template_dir:
88
+ console.print(f"[red]No template found for framework:[/red] {detected_framework!r}")
89
+ raise typer.Exit(code=1)
90
+
91
+ shared_dir = Path(__file__).parent.parent / "_shared"
92
+ search_paths = [str(template_dir)]
93
+ if shared_dir.is_dir():
94
+ search_paths.append(str(shared_dir))
95
+
96
+ env = Environment(
97
+ loader=FileSystemLoader(search_paths),
98
+ autoescape=select_autoescape([]),
99
+ keep_trailing_newline=True,
100
+ )
101
+ manifest_data = load_template_manifest(detected_framework)
102
+
103
+ group = COMPONENT_TO_GROUP[component]
104
+ group_files: dict[str, str] = manifest_data.get("files", {}).get(group, {})
105
+
106
+ if not group_files:
107
+ console.print(f"[yellow]No files to add for component:[/yellow] {component!r}")
108
+ return
109
+
110
+ wrote_any = False
111
+ for tmpl_name, out_rel in group_files.items():
112
+ out_path = project_path / out_rel
113
+ if out_path.exists() and not force:
114
+ console.print(
115
+ f"[yellow]Skipping[/yellow] {out_rel} (already exists -- use --force to overwrite)"
116
+ )
117
+ continue
118
+ out_path.parent.mkdir(parents=True, exist_ok=True)
119
+ content = env.get_template(tmpl_name).render(**options)
120
+ out_path.write_text(content, encoding="utf-8")
121
+ console.print(f"[green] +[/green] {out_rel}")
122
+ wrote_any = True
123
+
124
+ if wrote_any:
125
+ console.print(f"\n[bold green]{component}[/bold green] added successfully.")
126
+ else:
127
+ console.print(f"[dim]Nothing to do for {component!r}.[/dim]")
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Auto-detection helpers
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def _detect_framework(project_path: Path) -> str | None:
136
+ pyproject = project_path / "pyproject.toml"
137
+ if not pyproject.exists():
138
+ return None
139
+ text = pyproject.read_text(encoding="utf-8")
140
+ if "fastapi" in text.lower():
141
+ return "fastapi"
142
+ if "django" in text.lower():
143
+ return "django"
144
+ if "flask" in text.lower():
145
+ return "flask"
146
+ return None
147
+
148
+
149
+ def _detect_database(project_path: Path) -> str | None:
150
+ pyproject = project_path / "pyproject.toml"
151
+ if not pyproject.exists():
152
+ return None
153
+ text = pyproject.read_text(encoding="utf-8")
154
+ if "asyncpg" in text or "psycopg" in text:
155
+ return "postgresql"
156
+ if "aiosqlite" in text or "sqlite" in text.lower():
157
+ return "sqlite"
158
+ return "none"
159
+
160
+
161
+ def _detect_package_manager(project_path: Path) -> str | None:
162
+ if (project_path / "uv.lock").exists():
163
+ return "uv"
164
+ if (project_path / "poetry.lock").exists():
165
+ return "poetry"
166
+ return "pip"
167
+
168
+
169
+ def _find_template_dir(framework: str) -> Path | None:
170
+ base = Path(__file__).parent.parent / "templates" / framework
171
+ return base if base.is_dir() else None
@@ -0,0 +1,136 @@
1
+ """
2
+ ``pysnap create`` command implementation.
3
+
4
+ Handles both interactive (TTY) and non-interactive (flags-only) modes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Annotated, Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from pysnap.config import DEFAULT_OPTIONS, SUPPORTED_FRAMEWORKS
17
+ from pysnap.generator import generate_project
18
+
19
+ console = Console()
20
+
21
+
22
+ def create_command(
23
+ project_name: Annotated[str, typer.Argument(help="Name of the project directory to create")],
24
+ framework: Annotated[
25
+ Optional[str],
26
+ typer.Option("--framework", "-f", help="Web framework (fastapi, django, flask)"),
27
+ ] = None,
28
+ db: Annotated[
29
+ Optional[str],
30
+ typer.Option("--db", "-d", help="Database (sqlite, postgresql, none)"),
31
+ ] = None,
32
+ auth: Annotated[
33
+ Optional[bool],
34
+ typer.Option("--auth/--no-auth", help="Include JWT authentication"),
35
+ ] = None,
36
+ docker: Annotated[
37
+ Optional[bool],
38
+ typer.Option("--docker/--no-docker", help="Include Dockerfile and docker-compose"),
39
+ ] = None,
40
+ ci: Annotated[
41
+ Optional[bool],
42
+ typer.Option("--ci/--no-ci", help="Include GitHub Actions CI/CD"),
43
+ ] = None,
44
+ pm: Annotated[
45
+ Optional[str],
46
+ typer.Option("--pm", help="Package manager (uv, pip, poetry)"),
47
+ ] = None,
48
+ tests: Annotated[
49
+ Optional[bool],
50
+ typer.Option("--tests/--no-tests", help="Include pytest test suite"),
51
+ ] = None,
52
+ output: Annotated[
53
+ str,
54
+ typer.Option("--output", "-o", help="Output directory"),
55
+ ] = ".",
56
+ no_preview: Annotated[
57
+ bool,
58
+ typer.Option("--no-preview", help="Skip the file tree preview"),
59
+ ] = False,
60
+ no_validate: Annotated[
61
+ bool,
62
+ typer.Option("--no-validate", help="Skip post-generation validation"),
63
+ ] = False,
64
+ ) -> None:
65
+ """Scaffold a new Python project."""
66
+ # Build initial options dict from flags (None = not specified by user)
67
+ flag_options: dict = {
68
+ "framework": framework,
69
+ "database": db,
70
+ "include_auth": auth,
71
+ "include_docker": docker,
72
+ "include_ci": ci,
73
+ "package_manager": pm,
74
+ "include_tests": tests,
75
+ }
76
+
77
+ all_provided = all(v is not None for v in flag_options.values())
78
+ interactive = sys.stdin.isatty() and not all_provided
79
+
80
+ if interactive:
81
+ # Import here to avoid hard dependency when running non-interactively
82
+ from pysnap.prompts import ask_project_options
83
+
84
+ filled = ask_project_options(flag_options)
85
+ if filled is None:
86
+ raise typer.Abort()
87
+ options = filled
88
+ else:
89
+ # Fill any None values with defaults
90
+ options = {
91
+ k: (v if v is not None else DEFAULT_OPTIONS.get(k))
92
+ for k, v in flag_options.items()
93
+ }
94
+
95
+ options["project_name"] = project_name
96
+ options["project_name_slug"] = project_name.replace("-", "_").replace(" ", "_")
97
+
98
+ # Validate framework
99
+ known = set(SUPPORTED_FRAMEWORKS) | {"django", "flask"} # Always include built-ins
100
+ if options["framework"] not in known:
101
+ console.print(f"[red]Unknown framework:[/red] {options['framework']!r}")
102
+ raise typer.Exit(code=1)
103
+
104
+ # Show preview (unless skipped)
105
+ if not no_preview and sys.stdout.isatty():
106
+ from pysnap.generator import build_template_map, load_template_manifest
107
+ from pysnap.preview import show_preview
108
+
109
+ manifest_data = load_template_manifest(options["framework"])
110
+ template_map = build_template_map(manifest_data, options)
111
+ action = show_preview(template_map, options, console)
112
+ if action == "cancel":
113
+ raise typer.Abort()
114
+ if action == "back":
115
+ # Re-run interactively if possible
116
+ if sys.stdin.isatty():
117
+ from pysnap.prompts import ask_project_options
118
+
119
+ refilled = ask_project_options({})
120
+ if refilled is None:
121
+ raise typer.Abort()
122
+ options.update(refilled)
123
+ # else just proceed
124
+
125
+ project_path = generate_project(project_name, options, output, console)
126
+ if project_path is None:
127
+ raise typer.Exit(code=1)
128
+
129
+ # Post-generation validation (unless skipped)
130
+ if not no_validate:
131
+ from pysnap.validator import report_validation, validate_syntax
132
+
133
+ failures = validate_syntax(project_path)
134
+ report_validation(failures, console)
135
+ if failures:
136
+ raise typer.Exit(code=2)
@@ -0,0 +1,134 @@
1
+ """
2
+ ``pysnap templates`` subcommand group.
3
+
4
+ Provides ``pysnap templates list`` and ``pysnap templates search``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Annotated, Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ templates_app = typer.Typer(
18
+ name="templates",
19
+ help="Browse available project templates.",
20
+ add_completion=False,
21
+ )
22
+
23
+ console = Console()
24
+
25
+
26
+ @templates_app.command(name="list")
27
+ def templates_list() -> None:
28
+ """List all available templates (built-in + plugins)."""
29
+ rows = _collect_builtin_templates()
30
+
31
+ # Merge plugin templates
32
+ try:
33
+ from pysnap.plugins import discover_plugins
34
+ for plugin in discover_plugins():
35
+ if plugin.get("type") == "framework":
36
+ rows.append({
37
+ "name": plugin["name"],
38
+ "display_name": plugin.get("display_name", plugin["name"]),
39
+ "description": plugin.get("description", ""),
40
+ "source": plugin.get("_plugin_name", "Plugin"),
41
+ })
42
+ except Exception: # noqa: BLE001
43
+ pass
44
+
45
+ table = Table(title="Available Templates", show_header=True, header_style="bold cyan")
46
+ table.add_column("Name", style="bold")
47
+ table.add_column("Framework")
48
+ table.add_column("Description")
49
+ table.add_column("Source", style="dim")
50
+
51
+ for row in rows:
52
+ table.add_row(
53
+ row["name"],
54
+ row.get("display_name", row["name"]),
55
+ row.get("description", ""),
56
+ row.get("source", "Built-in"),
57
+ )
58
+
59
+ console.print(table)
60
+
61
+
62
+ @templates_app.command(name="search")
63
+ def templates_search(
64
+ keyword: Annotated[str, typer.Argument(help="Search keyword")],
65
+ framework: Annotated[
66
+ Optional[str],
67
+ typer.Option("--framework", "-f", help="Filter by framework"),
68
+ ] = None,
69
+ ) -> None:
70
+ """Search the community template registry."""
71
+ from pysnap.registry import fetch_registry, search_registry
72
+
73
+ console.print(f"[dim]Searching community registry for [bold]{keyword!r}[/bold]...[/dim]")
74
+
75
+ try:
76
+ entries = fetch_registry()
77
+ except Exception as exc:
78
+ console.print(f"[red]Error fetching registry:[/red] {exc}")
79
+ raise typer.Exit(code=1)
80
+
81
+ results = search_registry(entries, keyword, framework=framework)
82
+
83
+ if not results:
84
+ console.print("[yellow]No templates found matching your search.[/yellow]")
85
+ return
86
+
87
+ table = Table(
88
+ title=f"Community Templates -- '{keyword}'",
89
+ show_header=True,
90
+ header_style="bold cyan",
91
+ )
92
+ table.add_column("Name", style="bold")
93
+ table.add_column("Description")
94
+ table.add_column("Framework")
95
+ table.add_column("Stars", justify="right")
96
+ table.add_column("Author", style="dim")
97
+
98
+ for entry in results:
99
+ table.add_row(
100
+ entry.get("name", ""),
101
+ entry.get("description", ""),
102
+ entry.get("framework", ""),
103
+ str(entry.get("stars", 0)),
104
+ entry.get("author", ""),
105
+ )
106
+
107
+ console.print(table)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Helpers
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _collect_builtin_templates() -> list[dict]:
116
+ """Scan built-in template directories for template.json manifests."""
117
+ templates_dir = Path(__file__).parent.parent / "templates"
118
+ rows: list[dict] = []
119
+ if not templates_dir.is_dir():
120
+ return rows
121
+ for framework_dir in sorted(templates_dir.iterdir()):
122
+ manifest_path = framework_dir / "template.json"
123
+ if manifest_path.exists():
124
+ try:
125
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
126
+ rows.append({
127
+ "name": data.get("name", framework_dir.name),
128
+ "display_name": data.get("display_name", framework_dir.name),
129
+ "description": data.get("description", ""),
130
+ "source": "Built-in",
131
+ })
132
+ except Exception: # noqa: BLE001
133
+ pass
134
+ return rows