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
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 %}
|
pysnap/_shared/ci.yml.j2
ADDED
|
@@ -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,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 @@
|
|
|
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
|