codefortify-starter 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.
- codefortify_starter/__init__.py +7 -0
- codefortify_starter/__main__.py +6 -0
- codefortify_starter/cli.py +103 -0
- codefortify_starter/constants.py +48 -0
- codefortify_starter/generator.py +246 -0
- codefortify_starter/template_engine.py +63 -0
- codefortify_starter/templates/base_project/README.md.jinja +87 -0
- codefortify_starter/templates/base_project/apps/__init__.py +1 -0
- codefortify_starter/templates/base_project/apps/home/__init__.py +1 -0
- codefortify_starter/templates/base_project/apps/home/apps.py.jinja +7 -0
- codefortify_starter/templates/base_project/apps/home/templates/home/index.html.jinja +25 -0
- codefortify_starter/templates/base_project/apps/home/tests.py.jinja +17 -0
- codefortify_starter/templates/base_project/apps/home/urls.py.jinja +14 -0
- codefortify_starter/templates/base_project/apps/home/views.py.jinja +13 -0
- codefortify_starter/templates/base_project/core/__init__.py.jinja +7 -0
- codefortify_starter/templates/base_project/core/asgi.py.jinja +10 -0
- codefortify_starter/templates/base_project/core/env.py.jinja +46 -0
- codefortify_starter/templates/base_project/core/middleware/__init__.py +1 -0
- codefortify_starter/templates/base_project/core/middleware/exceptions.py +26 -0
- codefortify_starter/templates/base_project/core/middleware/security.py +12 -0
- codefortify_starter/templates/base_project/core/settings/__init__.py +1 -0
- codefortify_starter/templates/base_project/core/settings/base.py.jinja +160 -0
- codefortify_starter/templates/base_project/core/settings/dev.py.jinja +8 -0
- codefortify_starter/templates/base_project/core/settings/production.py.jinja +21 -0
- codefortify_starter/templates/base_project/core/templates/errors/400.html +8 -0
- codefortify_starter/templates/base_project/core/templates/errors/403.html +8 -0
- codefortify_starter/templates/base_project/core/templates/errors/404.html +8 -0
- codefortify_starter/templates/base_project/core/templates/errors/405.html +8 -0
- codefortify_starter/templates/base_project/core/templates/errors/500.html +8 -0
- codefortify_starter/templates/base_project/core/urls.py.jinja +23 -0
- codefortify_starter/templates/base_project/core/views.py.jinja +56 -0
- codefortify_starter/templates/base_project/core/wsgi.py.jinja +10 -0
- codefortify_starter/templates/base_project/dot-env.example.jinja +66 -0
- codefortify_starter/templates/base_project/dot-gitignore +34 -0
- codefortify_starter/templates/base_project/manage.py.jinja +16 -0
- codefortify_starter/templates/base_project/static/css/main.css +19 -0
- codefortify_starter/templates/base_project/templates/base.html.jinja +19 -0
- codefortify_starter/templates/features/celery/apps/home/tasks.py.jinja +7 -0
- codefortify_starter/templates/features/celery/core/celery.py.jinja +16 -0
- codefortify_starter/templates/features/docker/DOCKER_README.md.jinja +67 -0
- codefortify_starter/templates/features/docker/Dockerfile.jinja +20 -0
- codefortify_starter/templates/features/docker/docker-compose.prod.yml.jinja +158 -0
- codefortify_starter/templates/features/docker/docker-compose.yml.jinja +208 -0
- codefortify_starter/templates/features/docker/docker_deploy.sh.jinja +132 -0
- codefortify_starter/templates/features/docker/dot-dockerignore +12 -0
- codefortify_starter/templates/features/docker/entrypoint.sh.jinja +95 -0
- codefortify_starter/templates/features/drf/apps/api/__init__.py +1 -0
- codefortify_starter/templates/features/drf/apps/api/apps.py.jinja +7 -0
- codefortify_starter/templates/features/drf/apps/api/serializers.py +7 -0
- codefortify_starter/templates/features/drf/apps/api/tests.py +11 -0
- codefortify_starter/templates/features/drf/apps/api/urls.py +11 -0
- codefortify_starter/templates/features/drf/apps/api/views.py +16 -0
- codefortify_starter/templates/features/htmx/templates/partials/example.html.jinja +5 -0
- codefortify_starter/validators.py +70 -0
- codefortify_starter-1.0.0.dist-info/METADATA +276 -0
- codefortify_starter-1.0.0.dist-info/RECORD +60 -0
- codefortify_starter-1.0.0.dist-info/WHEEL +5 -0
- codefortify_starter-1.0.0.dist-info/entry_points.txt +2 -0
- codefortify_starter-1.0.0.dist-info/licenses/LICENSE +22 -0
- codefortify_starter-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""CLI entry point for project generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from codefortify_starter.generator import StarterProjectGenerator, build_generation_options
|
|
11
|
+
from codefortify_starter.validators import ValidationError, build_project_identity, normalize_database
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
project_name: str = typer.Argument(..., help="Project directory name to create."),
|
|
19
|
+
htmx: bool = typer.Option(False, "--htmx", help="Include HTMX integration."),
|
|
20
|
+
drf: bool = typer.Option(False, "--drf", help="Include Django REST Framework starter API."),
|
|
21
|
+
docker: bool = typer.Option(False, "--docker", help="Include Docker and Compose files."),
|
|
22
|
+
celery: bool = typer.Option(False, "--celery", help="Include Celery + Redis task setup."),
|
|
23
|
+
database: str | None = typer.Option(
|
|
24
|
+
None,
|
|
25
|
+
"--database",
|
|
26
|
+
help="Database backend to configure: sqlite, postgres, or mysql.",
|
|
27
|
+
),
|
|
28
|
+
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization."),
|
|
29
|
+
force: bool = typer.Option(False, "--force", help="Allow generation into an existing directory."),
|
|
30
|
+
directory: Path = typer.Option(Path("."), "--directory", help="Parent directory for the generated project."),
|
|
31
|
+
all_features: bool = typer.Option(
|
|
32
|
+
False,
|
|
33
|
+
"--all",
|
|
34
|
+
help="Enable full-stack preset: --htmx --drf --docker --celery --database postgres.",
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Generate a Django starter project."""
|
|
38
|
+
try:
|
|
39
|
+
if all_features:
|
|
40
|
+
htmx = True
|
|
41
|
+
drf = True
|
|
42
|
+
docker = True
|
|
43
|
+
celery = True
|
|
44
|
+
if database is None:
|
|
45
|
+
database = "postgres"
|
|
46
|
+
|
|
47
|
+
resolved_database = normalize_database(database, use_docker=docker)
|
|
48
|
+
identity = build_project_identity(project_name)
|
|
49
|
+
|
|
50
|
+
options = build_generation_options(
|
|
51
|
+
project_name=identity.project_name,
|
|
52
|
+
project_slug=identity.project_slug,
|
|
53
|
+
project_title=identity.project_title,
|
|
54
|
+
project_package=identity.project_package,
|
|
55
|
+
project_class_name=identity.project_class_name,
|
|
56
|
+
directory=directory.resolve(),
|
|
57
|
+
database=resolved_database,
|
|
58
|
+
use_htmx=htmx,
|
|
59
|
+
use_drf=drf,
|
|
60
|
+
use_docker=docker,
|
|
61
|
+
use_celery=celery,
|
|
62
|
+
no_git=no_git,
|
|
63
|
+
force=force,
|
|
64
|
+
)
|
|
65
|
+
generator = StarterProjectGenerator()
|
|
66
|
+
result = generator.generate(options)
|
|
67
|
+
except (ValidationError, FileExistsError, NotADirectoryError, RuntimeError) as exc:
|
|
68
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
69
|
+
raise typer.Exit(code=1) from exc
|
|
70
|
+
|
|
71
|
+
selected_features = []
|
|
72
|
+
if htmx:
|
|
73
|
+
selected_features.append("htmx")
|
|
74
|
+
if drf:
|
|
75
|
+
selected_features.append("drf")
|
|
76
|
+
if docker:
|
|
77
|
+
selected_features.append("docker")
|
|
78
|
+
if celery:
|
|
79
|
+
selected_features.append("celery")
|
|
80
|
+
features_text = ", ".join(selected_features) if selected_features else "base"
|
|
81
|
+
|
|
82
|
+
console.print(
|
|
83
|
+
f"[bold green]Created[/bold green] {result.project_path} "
|
|
84
|
+
f"(database={resolved_database}, features={features_text})"
|
|
85
|
+
)
|
|
86
|
+
if result.warnings:
|
|
87
|
+
for warning in result.warnings:
|
|
88
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
89
|
+
|
|
90
|
+
console.print("Next steps:")
|
|
91
|
+
console.print(f" 1. cd {result.project_path}")
|
|
92
|
+
console.print(" 2. python -m venv .venv && source .venv/bin/activate")
|
|
93
|
+
console.print(" 3. pip install -r requirements.txt")
|
|
94
|
+
console.print(" 4. cp .env.example .env")
|
|
95
|
+
console.print(" 5. python manage.py migrate && python manage.py runserver")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> None:
|
|
99
|
+
typer.run(run)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared constants for the starter generator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PACKAGE_NAME: Final[str] = "codefortify-starter"
|
|
9
|
+
IMPORT_PACKAGE: Final[str] = "codefortify_starter"
|
|
10
|
+
CLI_COMMAND: Final[str] = "codefortify-startproject"
|
|
11
|
+
PACKAGE_VERSION: Final[str] = "1.0.0"
|
|
12
|
+
|
|
13
|
+
VALID_DATABASES: Final[tuple[str, ...]] = ("sqlite", "postgres", "mysql")
|
|
14
|
+
|
|
15
|
+
BASE_REQUIREMENTS: Final[tuple[str, ...]] = (
|
|
16
|
+
"Django>=5.2,<5.3",
|
|
17
|
+
"python-decouple>=3.8,<4.0",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
FEATURE_REQUIREMENTS: Final[dict[str, tuple[str, ...]]] = {
|
|
21
|
+
"htmx": ("django-htmx>=1.20.0,<2.0",),
|
|
22
|
+
"drf": (
|
|
23
|
+
"djangorestframework>=3.15.0,<4.0",
|
|
24
|
+
"django-filter>=24.0,<26.0",
|
|
25
|
+
),
|
|
26
|
+
"docker": (
|
|
27
|
+
"gunicorn>=23.0.0,<24.0",
|
|
28
|
+
"whitenoise>=6.8.0,<7.0",
|
|
29
|
+
),
|
|
30
|
+
"celery": (
|
|
31
|
+
"celery>=5.4.0,<6.0",
|
|
32
|
+
"redis>=5.0.0,<6.0",
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DATABASE_REQUIREMENTS: Final[dict[str, tuple[str, ...]]] = {
|
|
37
|
+
"sqlite": (),
|
|
38
|
+
"postgres": (
|
|
39
|
+
"dj-database-url>=2.3.0,<3.0",
|
|
40
|
+
"psycopg2-binary>=2.9.9,<3.0",
|
|
41
|
+
),
|
|
42
|
+
"mysql": (
|
|
43
|
+
"dj-database-url>=2.3.0,<3.0",
|
|
44
|
+
"PyMySQL>=1.1.0,<2.0",
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
EXECUTABLE_FILES: Final[tuple[str, ...]] = ("entrypoint.sh", "docker_deploy.sh")
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Project generation orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from codefortify_starter.constants import (
|
|
13
|
+
BASE_REQUIREMENTS,
|
|
14
|
+
DATABASE_REQUIREMENTS,
|
|
15
|
+
EXECUTABLE_FILES,
|
|
16
|
+
FEATURE_REQUIREMENTS,
|
|
17
|
+
PACKAGE_VERSION,
|
|
18
|
+
)
|
|
19
|
+
from codefortify_starter.template_engine import TemplateEngine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
UNRESOLVED_TOKEN_RE = re.compile(r"(\[\[.+?\]\]|\[%.*?%\])", re.DOTALL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class GenerationOptions:
|
|
27
|
+
project_name: str
|
|
28
|
+
project_slug: str
|
|
29
|
+
project_title: str
|
|
30
|
+
project_package: str
|
|
31
|
+
project_class_name: str
|
|
32
|
+
target_root: Path
|
|
33
|
+
database: str
|
|
34
|
+
use_htmx: bool = False
|
|
35
|
+
use_drf: bool = False
|
|
36
|
+
use_docker: bool = False
|
|
37
|
+
use_celery: bool = False
|
|
38
|
+
use_git: bool = True
|
|
39
|
+
force: bool = False
|
|
40
|
+
package_version: str = PACKAGE_VERSION
|
|
41
|
+
created_by: str = "codefortify-starter"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def project_path(self) -> Path:
|
|
45
|
+
return self.target_root / self.project_name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class GenerationResult:
|
|
50
|
+
project_path: Path
|
|
51
|
+
requirements: list[str]
|
|
52
|
+
warnings: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StarterProjectGenerator:
|
|
56
|
+
"""Generates starter projects from base and feature template trees."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, template_root: Path | None = None) -> None:
|
|
59
|
+
self.template_root = template_root or (Path(__file__).resolve().parent / "templates")
|
|
60
|
+
self.engine = TemplateEngine()
|
|
61
|
+
|
|
62
|
+
def generate(self, options: GenerationOptions) -> GenerationResult:
|
|
63
|
+
warnings: list[str] = []
|
|
64
|
+
written_paths: set[Path] = set()
|
|
65
|
+
options.target_root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
project_path = options.project_path
|
|
67
|
+
|
|
68
|
+
if project_path.exists() and not options.force:
|
|
69
|
+
raise FileExistsError(f"Destination '{project_path}' already exists. Use --force to overwrite safely.")
|
|
70
|
+
if project_path.exists() and not project_path.is_dir():
|
|
71
|
+
raise NotADirectoryError(f"Destination '{project_path}' exists and is not a directory.")
|
|
72
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
context = self._build_context(options)
|
|
75
|
+
written_paths.update(self._render_base(context, project_path))
|
|
76
|
+
written_paths.update(self._render_feature_overlays(context, project_path, options))
|
|
77
|
+
|
|
78
|
+
requirements = self._build_requirements(options)
|
|
79
|
+
written_paths.add(self._write_requirements(project_path, requirements))
|
|
80
|
+
self._assert_no_unresolved_tokens(written_paths)
|
|
81
|
+
|
|
82
|
+
if options.use_docker:
|
|
83
|
+
self._set_executable_bits(project_path)
|
|
84
|
+
if options.database == "sqlite":
|
|
85
|
+
warnings.append("Docker + SQLite is supported for development only and is not recommended for production.")
|
|
86
|
+
|
|
87
|
+
if options.use_git:
|
|
88
|
+
warning = self._initialize_git(project_path)
|
|
89
|
+
if warning:
|
|
90
|
+
warnings.append(warning)
|
|
91
|
+
|
|
92
|
+
return GenerationResult(project_path=project_path, requirements=requirements, warnings=warnings)
|
|
93
|
+
|
|
94
|
+
def _render_base(self, context: dict[str, object], project_path: Path) -> set[Path]:
|
|
95
|
+
base_dir = self.template_root / "base_project"
|
|
96
|
+
written_paths = self.engine.render_tree(base_dir, project_path, context)
|
|
97
|
+
(project_path / "media").mkdir(exist_ok=True)
|
|
98
|
+
media_gitkeep = project_path / "media" / ".gitkeep"
|
|
99
|
+
media_gitkeep.touch(exist_ok=True)
|
|
100
|
+
written_paths.add(media_gitkeep)
|
|
101
|
+
return written_paths
|
|
102
|
+
|
|
103
|
+
def _render_feature_overlays(
|
|
104
|
+
self,
|
|
105
|
+
context: dict[str, object],
|
|
106
|
+
project_path: Path,
|
|
107
|
+
options: GenerationOptions,
|
|
108
|
+
) -> set[Path]:
|
|
109
|
+
written_paths: set[Path] = set()
|
|
110
|
+
feature_flags = {
|
|
111
|
+
"htmx": options.use_htmx,
|
|
112
|
+
"drf": options.use_drf,
|
|
113
|
+
"docker": options.use_docker,
|
|
114
|
+
"celery": options.use_celery,
|
|
115
|
+
}
|
|
116
|
+
for feature_name, enabled in feature_flags.items():
|
|
117
|
+
if not enabled:
|
|
118
|
+
continue
|
|
119
|
+
feature_dir = self.template_root / "features" / feature_name
|
|
120
|
+
written_paths.update(self.engine.render_tree(feature_dir, project_path, context))
|
|
121
|
+
return written_paths
|
|
122
|
+
|
|
123
|
+
def _build_context(self, options: GenerationOptions) -> dict[str, object]:
|
|
124
|
+
return {
|
|
125
|
+
"project_name": options.project_name,
|
|
126
|
+
"project_slug": options.project_slug,
|
|
127
|
+
"project_title": options.project_title,
|
|
128
|
+
"project_package": options.project_package,
|
|
129
|
+
"project_class_name": options.project_class_name,
|
|
130
|
+
"database": options.database,
|
|
131
|
+
"use_htmx": options.use_htmx,
|
|
132
|
+
"use_drf": options.use_drf,
|
|
133
|
+
"use_docker": options.use_docker,
|
|
134
|
+
"use_celery": options.use_celery,
|
|
135
|
+
"use_postgres": options.database == "postgres",
|
|
136
|
+
"use_mysql": options.database == "mysql",
|
|
137
|
+
"use_sqlite": options.database == "sqlite",
|
|
138
|
+
"use_git": options.use_git,
|
|
139
|
+
"package_version": options.package_version,
|
|
140
|
+
"created_by": options.created_by,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _build_requirements(self, options: GenerationOptions) -> list[str]:
|
|
144
|
+
dependencies: list[str] = list(BASE_REQUIREMENTS)
|
|
145
|
+
|
|
146
|
+
if options.use_htmx:
|
|
147
|
+
dependencies.extend(FEATURE_REQUIREMENTS["htmx"])
|
|
148
|
+
if options.use_drf:
|
|
149
|
+
dependencies.extend(FEATURE_REQUIREMENTS["drf"])
|
|
150
|
+
if options.use_docker:
|
|
151
|
+
dependencies.extend(FEATURE_REQUIREMENTS["docker"])
|
|
152
|
+
if options.use_celery:
|
|
153
|
+
dependencies.extend(FEATURE_REQUIREMENTS["celery"])
|
|
154
|
+
|
|
155
|
+
dependencies.extend(DATABASE_REQUIREMENTS[options.database])
|
|
156
|
+
|
|
157
|
+
# Stable dedupe order
|
|
158
|
+
deduped = list(dict.fromkeys(dependencies))
|
|
159
|
+
return deduped
|
|
160
|
+
|
|
161
|
+
def _write_requirements(self, project_path: Path, dependencies: list[str]) -> Path:
|
|
162
|
+
requirements_path = project_path / "requirements.txt"
|
|
163
|
+
body = "\n".join(dependencies) + "\n"
|
|
164
|
+
requirements_path.write_text(body, encoding="utf-8")
|
|
165
|
+
return requirements_path
|
|
166
|
+
|
|
167
|
+
def _set_executable_bits(self, project_path: Path) -> None:
|
|
168
|
+
for relative_path in EXECUTABLE_FILES:
|
|
169
|
+
file_path = project_path / relative_path
|
|
170
|
+
if not file_path.exists():
|
|
171
|
+
continue
|
|
172
|
+
mode = file_path.stat().st_mode
|
|
173
|
+
file_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
174
|
+
|
|
175
|
+
def _assert_no_unresolved_tokens(self, paths: set[Path]) -> None:
|
|
176
|
+
for path in sorted(paths):
|
|
177
|
+
if not path.exists() or not path.is_file():
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
content = path.read_text(encoding="utf-8")
|
|
181
|
+
except UnicodeDecodeError:
|
|
182
|
+
continue
|
|
183
|
+
if UNRESOLVED_TOKEN_RE.search(content):
|
|
184
|
+
raise RuntimeError(f"Unresolved template token found in generated file: {path}")
|
|
185
|
+
|
|
186
|
+
def _initialize_git(self, project_path: Path) -> str | None:
|
|
187
|
+
try:
|
|
188
|
+
completed = subprocess.run(
|
|
189
|
+
["git", "init"],
|
|
190
|
+
cwd=project_path,
|
|
191
|
+
check=False,
|
|
192
|
+
stdout=subprocess.DEVNULL,
|
|
193
|
+
stderr=subprocess.PIPE,
|
|
194
|
+
text=True,
|
|
195
|
+
)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return "Git is not installed; skipped repository initialization."
|
|
198
|
+
|
|
199
|
+
if completed.returncode != 0:
|
|
200
|
+
message = (completed.stderr or "").strip() or "Unknown git init failure."
|
|
201
|
+
return f"Failed to initialize git repository: {message}"
|
|
202
|
+
|
|
203
|
+
# Avoid hard failure if user shell/environment does not support this command.
|
|
204
|
+
subprocess.run(
|
|
205
|
+
["git", "symbolic-ref", "HEAD", "refs/heads/main"],
|
|
206
|
+
cwd=project_path,
|
|
207
|
+
check=False,
|
|
208
|
+
stdout=subprocess.DEVNULL,
|
|
209
|
+
stderr=subprocess.DEVNULL,
|
|
210
|
+
)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_generation_options(
|
|
215
|
+
*,
|
|
216
|
+
project_name: str,
|
|
217
|
+
project_slug: str,
|
|
218
|
+
project_title: str,
|
|
219
|
+
project_package: str,
|
|
220
|
+
project_class_name: str,
|
|
221
|
+
directory: Path,
|
|
222
|
+
database: str,
|
|
223
|
+
use_htmx: bool,
|
|
224
|
+
use_drf: bool,
|
|
225
|
+
use_docker: bool,
|
|
226
|
+
use_celery: bool,
|
|
227
|
+
no_git: bool,
|
|
228
|
+
force: bool,
|
|
229
|
+
) -> GenerationOptions:
|
|
230
|
+
created_by = os.environ.get("USER") or os.environ.get("USERNAME") or "codefortify"
|
|
231
|
+
return GenerationOptions(
|
|
232
|
+
project_name=project_name,
|
|
233
|
+
project_slug=project_slug,
|
|
234
|
+
project_title=project_title,
|
|
235
|
+
project_package=project_package,
|
|
236
|
+
project_class_name=project_class_name,
|
|
237
|
+
target_root=directory,
|
|
238
|
+
database=database,
|
|
239
|
+
use_htmx=use_htmx,
|
|
240
|
+
use_drf=use_drf,
|
|
241
|
+
use_docker=use_docker,
|
|
242
|
+
use_celery=use_celery,
|
|
243
|
+
use_git=not no_git,
|
|
244
|
+
force=force,
|
|
245
|
+
created_by=created_by,
|
|
246
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Filesystem + Jinja template rendering utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, StrictUndefined
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateEngine:
|
|
12
|
+
"""Renders template trees using custom Jinja delimiters."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._environment = Environment(
|
|
16
|
+
autoescape=False,
|
|
17
|
+
keep_trailing_newline=True,
|
|
18
|
+
trim_blocks=False,
|
|
19
|
+
lstrip_blocks=False,
|
|
20
|
+
undefined=StrictUndefined,
|
|
21
|
+
variable_start_string="[[",
|
|
22
|
+
variable_end_string="]]",
|
|
23
|
+
block_start_string="[%", # avoids collision with Django template tags
|
|
24
|
+
block_end_string="%]",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def render_tree(self, source_root: Path, destination_root: Path, context: dict[str, object]) -> set[Path]:
|
|
28
|
+
"""Render files from `source_root` into `destination_root`."""
|
|
29
|
+
written_paths: set[Path] = set()
|
|
30
|
+
if not source_root.exists():
|
|
31
|
+
return written_paths
|
|
32
|
+
|
|
33
|
+
for source_path in sorted(source_root.rglob("*")):
|
|
34
|
+
relative = source_path.relative_to(source_root)
|
|
35
|
+
destination_path = destination_root / self._normalized_relative_path(relative)
|
|
36
|
+
if source_path.is_dir():
|
|
37
|
+
destination_path.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
if source_path.suffix == ".jinja":
|
|
42
|
+
rendered = self.render_text(source_path.read_text(encoding="utf-8"), context)
|
|
43
|
+
destination_path.write_text(rendered, encoding="utf-8")
|
|
44
|
+
else:
|
|
45
|
+
shutil.copy2(source_path, destination_path)
|
|
46
|
+
written_paths.add(destination_path)
|
|
47
|
+
|
|
48
|
+
return written_paths
|
|
49
|
+
|
|
50
|
+
def render_text(self, template_source: str, context: dict[str, object]) -> str:
|
|
51
|
+
template = self._environment.from_string(template_source)
|
|
52
|
+
return template.render(**context)
|
|
53
|
+
|
|
54
|
+
def _normalized_relative_path(self, relative_path: Path) -> Path:
|
|
55
|
+
parts = []
|
|
56
|
+
for part in relative_path.parts:
|
|
57
|
+
normalized = part
|
|
58
|
+
if normalized.startswith("dot-"):
|
|
59
|
+
normalized = f".{normalized[4:]}"
|
|
60
|
+
if normalized.endswith(".jinja"):
|
|
61
|
+
normalized = normalized[: -len(".jinja")]
|
|
62
|
+
parts.append(normalized)
|
|
63
|
+
return Path(*parts)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# [[ project_title ]]
|
|
2
|
+
|
|
3
|
+
Generated with `codefortify-starter` ([[ package_version ]]).
|
|
4
|
+
|
|
5
|
+
## Selected features
|
|
6
|
+
|
|
7
|
+
- Database: `[[ database ]]`
|
|
8
|
+
- HTMX: `[% if use_htmx %]enabled[% else %]disabled[% endif %]`
|
|
9
|
+
- DRF: `[% if use_drf %]enabled[% else %]disabled[% endif %]`
|
|
10
|
+
- Docker: `[% if use_docker %]enabled[% else %]disabled[% endif %]`
|
|
11
|
+
- Celery: `[% if use_celery %]enabled[% else %]disabled[% endif %]`
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m venv .venv
|
|
17
|
+
source .venv/bin/activate
|
|
18
|
+
pip install -r requirements.txt
|
|
19
|
+
cp .env.example .env
|
|
20
|
+
python manage.py migrate
|
|
21
|
+
python manage.py runserver
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run checks:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python manage.py check
|
|
28
|
+
python manage.py test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
[% if use_drf %]
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
Base API route:
|
|
35
|
+
|
|
36
|
+
- `GET /api/health/`
|
|
37
|
+
|
|
38
|
+
[% endif %]
|
|
39
|
+
[% if use_htmx %]
|
|
40
|
+
## HTMX
|
|
41
|
+
|
|
42
|
+
HTMX demo endpoint:
|
|
43
|
+
|
|
44
|
+
- `GET /htmx/example/`
|
|
45
|
+
|
|
46
|
+
[% endif %]
|
|
47
|
+
[% if use_celery %]
|
|
48
|
+
## Celery
|
|
49
|
+
|
|
50
|
+
Run worker locally:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
celery -A core worker -l info
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Redis must be available at `REDIS_URL`.
|
|
57
|
+
|
|
58
|
+
[% endif %]
|
|
59
|
+
[% if use_docker %]
|
|
60
|
+
## Docker
|
|
61
|
+
|
|
62
|
+
Development compose:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
docker compose up -d --build
|
|
66
|
+
docker compose exec web python manage.py check
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
See `DOCKER_README.md` for deployment details.
|
|
70
|
+
|
|
71
|
+
[% endif %]
|
|
72
|
+
## Project layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
.
|
|
76
|
+
|-- apps/
|
|
77
|
+
| |-- home/
|
|
78
|
+
[% if use_drf %]| `-- api/
|
|
79
|
+
[% endif %]|-- core/
|
|
80
|
+
| `-- settings/
|
|
81
|
+
|-- templates/
|
|
82
|
+
|-- static/
|
|
83
|
+
|-- media/
|
|
84
|
+
|-- requirements.txt
|
|
85
|
+
`-- manage.py
|
|
86
|
+
```
|
|
87
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Home | [[ project_title ]]{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<h1>[[ project_title ]]</h1>
|
|
7
|
+
<p>Generated with codefortify-starter.</p>
|
|
8
|
+
|
|
9
|
+
[% if use_htmx %]
|
|
10
|
+
<section>
|
|
11
|
+
<button
|
|
12
|
+
hx-get="{% url 'home:htmx-example' %}"
|
|
13
|
+
hx-target="#htmx-fragment"
|
|
14
|
+
hx-swap="innerHTML"
|
|
15
|
+
type="button"
|
|
16
|
+
>
|
|
17
|
+
Load HTMX Fragment
|
|
18
|
+
</button>
|
|
19
|
+
<div id="htmx-fragment">
|
|
20
|
+
{% include "partials/example.html" %}
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
[% endif %]
|
|
24
|
+
{% endblock %}
|
|
25
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HomeViewTests(TestCase):
|
|
6
|
+
def test_home_page_renders(self):
|
|
7
|
+
response = self.client.get(reverse("home:home"))
|
|
8
|
+
self.assertEqual(response.status_code, 200)
|
|
9
|
+
self.assertTemplateUsed(response, "home/index.html")
|
|
10
|
+
|
|
11
|
+
[% if use_htmx %]
|
|
12
|
+
def test_htmx_example_partial_renders(self):
|
|
13
|
+
response = self.client.get(reverse("home:htmx-example"), HTTP_HX_REQUEST="true")
|
|
14
|
+
self.assertEqual(response.status_code, 200)
|
|
15
|
+
self.assertTemplateUsed(response, "partials/example.html")
|
|
16
|
+
[% endif %]
|
|
17
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from apps.home.views import HomeView[% if use_htmx %], htmx_example[% endif %]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
app_name = "home"
|
|
7
|
+
|
|
8
|
+
urlpatterns = [
|
|
9
|
+
path("", HomeView.as_view(), name="home"),
|
|
10
|
+
[% if use_htmx %]
|
|
11
|
+
path("htmx/example/", htmx_example, name="htmx-example"),
|
|
12
|
+
[% endif %]
|
|
13
|
+
]
|
|
14
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.shortcuts import render
|
|
2
|
+
from django.views.generic import TemplateView
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HomeView(TemplateView):
|
|
6
|
+
template_name = "home/index.html"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
[% if use_htmx %]
|
|
10
|
+
def htmx_example(request):
|
|
11
|
+
return render(request, "partials/example.html")
|
|
12
|
+
[% endif %]
|
|
13
|
+
|