tses 0.1.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.
- tses/__init__.py +3 -0
- tses/__main__.py +7 -0
- tses/cli.py +79 -0
- tses/commands/__init__.py +1 -0
- tses/commands/startproject.py +75 -0
- tses/core/__init__.py +1 -0
- tses/core/errors.py +17 -0
- tses/core/filesystem.py +91 -0
- tses/core/generator.py +90 -0
- tses/core/headers.py +78 -0
- tses/core/metadata.py +55 -0
- tses/core/renderer.py +48 -0
- tses/templates/__init__.py +1 -0
- tses/templates/django/.docker/Dockerfile +22 -0
- tses/templates/django/.docker/entrypoint.sh +15 -0
- tses/templates/django/.dockerignore +24 -0
- tses/templates/django/.env.example +32 -0
- tses/templates/django/.github/workflows/deploy.yaml +42 -0
- tses/templates/django/.gitignore +27 -0
- tses/templates/django/README.md +442 -0
- tses/templates/django/api/__init__.py +0 -0
- tses/templates/django/api/authentication/__init__.py +1 -0
- tses/templates/django/api/authentication/utils/__init__.py +3 -0
- tses/templates/django/api/authentication/utils/base_auth.py +35 -0
- tses/templates/django/api/authorization/__init__.py +0 -0
- tses/templates/django/api/authorization/admin.py +3 -0
- tses/templates/django/api/authorization/apps.py +6 -0
- tses/templates/django/api/authorization/migrations/__init__.py +0 -0
- tses/templates/django/api/authorization/models/__init__.py +0 -0
- tses/templates/django/api/authorization/serializers/__init__.py +0 -0
- tses/templates/django/api/authorization/tests.py +3 -0
- tses/templates/django/api/authorization/views/__init__.py +0 -0
- tses/templates/django/api/notification/__init__.py +0 -0
- tses/templates/django/api/notification/admin.py +3 -0
- tses/templates/django/api/notification/apps.py +6 -0
- tses/templates/django/api/notification/enums.py +26 -0
- tses/templates/django/api/notification/migrations/0001_initial.py +34 -0
- tses/templates/django/api/notification/migrations/0002_initial.py +31 -0
- tses/templates/django/api/notification/migrations/__init__.py +0 -0
- tses/templates/django/api/notification/models.py +244 -0
- tses/templates/django/api/notification/serializers.py +0 -0
- tses/templates/django/api/notification/tests.py +3 -0
- tses/templates/django/api/notification/views.py +0 -0
- tses/templates/django/api/users/__init__.py +0 -0
- tses/templates/django/api/users/admin.py +3 -0
- tses/templates/django/api/users/apps.py +6 -0
- tses/templates/django/api/users/enums.py +31 -0
- tses/templates/django/api/users/migrations/0001_initial.py +72 -0
- tses/templates/django/api/users/migrations/0002_alter_user_created_by_alter_user_updated_by.py +25 -0
- tses/templates/django/api/users/migrations/__init__.py +0 -0
- tses/templates/django/api/users/models/__init__.py +7 -0
- tses/templates/django/api/users/models/activity_log.py +123 -0
- tses/templates/django/api/users/models/manager.py +37 -0
- tses/templates/django/api/users/models/user.py +37 -0
- tses/templates/django/api/users/serializers/__init__.py +0 -0
- tses/templates/django/api/users/tests.py +3 -0
- tses/templates/django/api/users/views/__init__.py +0 -0
- tses/templates/django/config/__init__.py +3 -0
- tses/templates/django/config/asgi.py +16 -0
- tses/templates/django/config/celery.py +19 -0
- tses/templates/django/config/settings/__init__.py +25 -0
- tses/templates/django/config/settings/app_registry.py +26 -0
- tses/templates/django/config/settings/celery.py +12 -0
- tses/templates/django/config/settings/commons.py +115 -0
- tses/templates/django/config/settings/cors.py +12 -0
- tses/templates/django/config/settings/db.py +12 -0
- tses/templates/django/config/settings/drf.py +38 -0
- tses/templates/django/config/settings/exception.py +6 -0
- tses/templates/django/config/settings/jwt.py +31 -0
- tses/templates/django/config/settings/middleware.py +11 -0
- tses/templates/django/config/settings/s3.py +0 -0
- tses/templates/django/config/settings/smtp.py +16 -0
- tses/templates/django/config/urls.py +22 -0
- tses/templates/django/config/wsgi.py +16 -0
- tses/templates/django/docker-compose.yaml +52 -0
- tses/templates/django/includes/__init__.py +0 -0
- tses/templates/django/includes/helpers/__init__.py +11 -0
- tses/templates/django/includes/helpers/formatter.py +25 -0
- tses/templates/django/includes/helpers/models.py +73 -0
- tses/templates/django/includes/helpers/pagination.py +217 -0
- tses/templates/django/includes/third_party/__init__.py +0 -0
- tses/templates/django/includes/third_party/exceptions.py +42 -0
- tses/templates/django/includes/third_party/processor.py +59 -0
- tses/templates/django/manage.py +22 -0
- tses/templates/django/requirements.txt +11 -0
- tses/templates/fastapi/.docker/Dockerfile +22 -0
- tses/templates/fastapi/.docker/entrypoint.sh +8 -0
- tses/templates/fastapi/.dockerignore +10 -0
- tses/templates/fastapi/.env.example +29 -0
- tses/templates/fastapi/.github/workflows/deploy.yaml +42 -0
- tses/templates/fastapi/README.md +516 -0
- tses/templates/fastapi/alembic/env.py +78 -0
- tses/templates/fastapi/alembic/script.py.mako +20 -0
- tses/templates/fastapi/alembic/versions/0001_initial.py +73 -0
- tses/templates/fastapi/alembic/versions/__init__.py +1 -0
- tses/templates/fastapi/alembic.ini +36 -0
- tses/templates/fastapi/api/__init__.py +0 -0
- tses/templates/fastapi/api/auth/__init__.py +24 -0
- tses/templates/fastapi/api/auth/base_auth.py +35 -0
- tses/templates/fastapi/api/auth/schemas.py +15 -0
- tses/templates/fastapi/api/auth/services/__init__.py +8 -0
- tses/templates/fastapi/api/auth/services/auth.py +94 -0
- tses/templates/fastapi/api/auth/services/token.py +86 -0
- tses/templates/fastapi/api/health/__init__.py +0 -0
- tses/templates/fastapi/api/health/routers.py +44 -0
- tses/templates/fastapi/api/health/schemas.py +24 -0
- tses/templates/fastapi/api/health/service.py +59 -0
- tses/templates/fastapi/api/users/__init__.py +1 -0
- tses/templates/fastapi/api/users/models/__init__.py +3 -0
- tses/templates/fastapi/api/users/models/users.py +67 -0
- tses/templates/fastapi/api/users/routers.py +12 -0
- tses/templates/fastapi/api/users/schemas.py +24 -0
- tses/templates/fastapi/config/__init__.py +0 -0
- tses/templates/fastapi/config/db.py +99 -0
- tses/templates/fastapi/config/settings.py +161 -0
- tses/templates/fastapi/docker-compose.yaml +20 -0
- tses/templates/fastapi/includes/__init__.py +1 -0
- tses/templates/fastapi/includes/errors.py +57 -0
- tses/templates/fastapi/includes/exception_handlers.py +155 -0
- tses/templates/fastapi/includes/libs/__init__.py +4 -0
- tses/templates/fastapi/includes/libs/s3.py +348 -0
- tses/templates/fastapi/includes/middleware/__init__.py +3 -0
- tses/templates/fastapi/includes/middleware/error_handler.py +30 -0
- tses/templates/fastapi/includes/models.py +170 -0
- tses/templates/fastapi/includes/pagination.py +148 -0
- tses/templates/fastapi/includes/schemas.py +52 -0
- tses/templates/fastapi/includes/third_party/__init__.py +0 -0
- tses/templates/fastapi/includes/third_party/exceptions.py +42 -0
- tses/templates/fastapi/includes/third_party/processor.py +59 -0
- tses/templates/fastapi/main.py +72 -0
- tses/templates/fastapi/requirements.txt +13 -0
- tses-0.1.0.dist-info/METADATA +79 -0
- tses-0.1.0.dist-info/RECORD +136 -0
- tses-0.1.0.dist-info/WHEEL +4 -0
- tses-0.1.0.dist-info/entry_points.txt +2 -0
- tses-0.1.0.dist-info/licenses/LICENSE +7 -0
tses/__init__.py
ADDED
tses/__main__.py
ADDED
tses/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line interface for the TSES scaffold generator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from tses import __version__
|
|
9
|
+
from tses.commands.startproject import handle_startproject
|
|
10
|
+
from tses.core.errors import TsesError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Build the top-level CLI parser."""
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="tses",
|
|
18
|
+
description="Generate curated Django or FastAPI project scaffolds.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--version",
|
|
22
|
+
action="version",
|
|
23
|
+
version=f"tses {__version__}",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
|
|
28
|
+
startproject_parser = subparsers.add_parser(
|
|
29
|
+
"startproject",
|
|
30
|
+
help="Generate a project scaffold into the target directory.",
|
|
31
|
+
)
|
|
32
|
+
startproject_parser.add_argument(
|
|
33
|
+
"target",
|
|
34
|
+
help="Target directory to generate into. Use '.' for the current directory.",
|
|
35
|
+
)
|
|
36
|
+
startproject_parser.add_argument(
|
|
37
|
+
"-f",
|
|
38
|
+
"--framework",
|
|
39
|
+
required=True,
|
|
40
|
+
choices=("django", "fastapi"),
|
|
41
|
+
help="Framework scaffold to generate.",
|
|
42
|
+
)
|
|
43
|
+
startproject_parser.add_argument(
|
|
44
|
+
"--force",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Overwrite scaffold-managed files in a non-empty target directory.",
|
|
47
|
+
)
|
|
48
|
+
startproject_parser.add_argument(
|
|
49
|
+
"--dry-run",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Print planned actions without writing files.",
|
|
52
|
+
)
|
|
53
|
+
startproject_parser.add_argument(
|
|
54
|
+
"--verbose",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Print per-file actions during generation.",
|
|
57
|
+
)
|
|
58
|
+
startproject_parser.set_defaults(handler=handle_startproject)
|
|
59
|
+
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
"""Run the CLI and return an exit code."""
|
|
65
|
+
|
|
66
|
+
parser = build_parser()
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return args.handler(args)
|
|
71
|
+
except TsesError as exc:
|
|
72
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run() -> None:
|
|
77
|
+
"""Run the CLI as a console-script entry point."""
|
|
78
|
+
|
|
79
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command handlers for TSES."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Implementation of the `tses startproject` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tses.core.filesystem import PlannedAction
|
|
8
|
+
from tses.core.generator import GenerationResult, generate_scaffold
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handle_startproject(args) -> int:
|
|
12
|
+
"""Generate the requested scaffold and print user-facing output."""
|
|
13
|
+
|
|
14
|
+
result = generate_scaffold(
|
|
15
|
+
framework=args.framework,
|
|
16
|
+
target=Path(args.target),
|
|
17
|
+
force=args.force,
|
|
18
|
+
dry_run=args.dry_run,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if args.dry_run:
|
|
22
|
+
print(f"Dry run for {result.framework} scaffold")
|
|
23
|
+
print(f"Target: {result.target_dir}")
|
|
24
|
+
for action in result.actions:
|
|
25
|
+
_print_action(action, prefix="Would")
|
|
26
|
+
print(
|
|
27
|
+
f"Would create {result.created_count} files and overwrite "
|
|
28
|
+
f"{result.overwritten_count} files."
|
|
29
|
+
)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
if args.verbose:
|
|
33
|
+
for action in result.actions:
|
|
34
|
+
_print_action(action)
|
|
35
|
+
|
|
36
|
+
print("Project generated successfully.")
|
|
37
|
+
print(f"Framework: {result.framework}")
|
|
38
|
+
print(f"Target: {result.target_dir}")
|
|
39
|
+
print(f"Project name: {result.metadata.project_name}")
|
|
40
|
+
print(f"Force mode: {'yes' if args.force else 'no'}")
|
|
41
|
+
print(f"Created files: {result.created_count}")
|
|
42
|
+
print(f"Overwritten files: {result.overwritten_count}")
|
|
43
|
+
print("Next steps:")
|
|
44
|
+
for step in _build_next_steps(result):
|
|
45
|
+
print(f" {step}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_action(action: PlannedAction, prefix: str | None = None) -> None:
|
|
50
|
+
"""Print one planned or completed file action."""
|
|
51
|
+
|
|
52
|
+
verb = action.kind if prefix is None else f"{prefix} {action.kind}"
|
|
53
|
+
print(f"{verb:>16} {action.relative_path.as_posix()}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_next_steps(result: GenerationResult) -> list[str]:
|
|
57
|
+
"""Return framework-specific follow-up steps for the user."""
|
|
58
|
+
|
|
59
|
+
steps: list[str] = []
|
|
60
|
+
if result.original_target != Path("."):
|
|
61
|
+
steps.append(f"cd {result.target_dir}")
|
|
62
|
+
|
|
63
|
+
env_example = result.target_dir / ".env.example"
|
|
64
|
+
env_file = result.target_dir / ".env"
|
|
65
|
+
if env_example.exists() and not env_file.exists():
|
|
66
|
+
steps.append("cp .env.example .env")
|
|
67
|
+
|
|
68
|
+
if result.framework == "django":
|
|
69
|
+
steps.append("docker compose up --build")
|
|
70
|
+
steps.append("python manage.py migrate")
|
|
71
|
+
else:
|
|
72
|
+
steps.append("docker compose up --build")
|
|
73
|
+
steps.append("alembic upgrade head")
|
|
74
|
+
|
|
75
|
+
return steps
|
tses/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core generation utilities for TSES."""
|
tses/core/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exceptions raised by the TSES scaffold generator."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TsesError(Exception):
|
|
5
|
+
"""Base exception for user-facing generator errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedFrameworkError(TsesError):
|
|
9
|
+
"""Raised when a framework choice is not supported."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsafeTargetError(TsesError):
|
|
13
|
+
"""Raised when generation would write into an unsafe target directory."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GenerationError(TsesError):
|
|
17
|
+
"""Raised when file generation cannot complete safely."""
|
tses/core/filesystem.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Filesystem planning helpers for scaffold generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator
|
|
8
|
+
|
|
9
|
+
from importlib.abc import Traversable
|
|
10
|
+
|
|
11
|
+
from tses.core.errors import GenerationError, UnsafeTargetError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PlannedAction:
|
|
16
|
+
"""One file action required to realize a scaffold generation request."""
|
|
17
|
+
|
|
18
|
+
kind: str
|
|
19
|
+
relative_path: Path
|
|
20
|
+
source: Traversable
|
|
21
|
+
destination: Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prepare_target_directory(target_dir: Path, *, force: bool, dry_run: bool) -> None:
|
|
25
|
+
"""Validate the target directory and create it when appropriate."""
|
|
26
|
+
|
|
27
|
+
if target_dir.exists() and not target_dir.is_dir():
|
|
28
|
+
raise UnsafeTargetError(f"Target path is not a directory: {target_dir}")
|
|
29
|
+
|
|
30
|
+
if target_dir.exists():
|
|
31
|
+
if any(target_dir.iterdir()) and not force:
|
|
32
|
+
raise UnsafeTargetError(
|
|
33
|
+
"Target directory is not empty. Re-run with --force to overwrite "
|
|
34
|
+
"scaffold-managed files."
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if not dry_run:
|
|
39
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plan_actions(template_root: Traversable, target_dir: Path) -> list[PlannedAction]:
|
|
43
|
+
"""Plan the file actions required to copy a scaffold into the target."""
|
|
44
|
+
|
|
45
|
+
actions: list[PlannedAction] = []
|
|
46
|
+
for source, relative_path in iter_template_files(template_root):
|
|
47
|
+
destination = target_dir / relative_path
|
|
48
|
+
_validate_parent_paths(target_dir=target_dir, destination=destination)
|
|
49
|
+
|
|
50
|
+
if destination.exists() and destination.is_dir():
|
|
51
|
+
raise GenerationError(
|
|
52
|
+
f"Cannot overwrite directory with file: {destination}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
action_kind = "overwrite" if destination.exists() else "create"
|
|
56
|
+
actions.append(
|
|
57
|
+
PlannedAction(
|
|
58
|
+
kind=action_kind,
|
|
59
|
+
relative_path=relative_path,
|
|
60
|
+
source=source,
|
|
61
|
+
destination=destination,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return actions
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def iter_template_files(
|
|
69
|
+
root: Traversable,
|
|
70
|
+
relative_path: Path = Path(),
|
|
71
|
+
) -> Iterator[tuple[Traversable, Path]]:
|
|
72
|
+
"""Yield every file under a template root with a stable relative path."""
|
|
73
|
+
|
|
74
|
+
for child in sorted(root.iterdir(), key=lambda entry: entry.name):
|
|
75
|
+
child_relative_path = relative_path / child.name
|
|
76
|
+
if child.is_dir():
|
|
77
|
+
yield from iter_template_files(child, child_relative_path)
|
|
78
|
+
elif child.is_file():
|
|
79
|
+
yield child, child_relative_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _validate_parent_paths(*, target_dir: Path, destination: Path) -> None:
|
|
83
|
+
"""Ensure no parent segment that should be a directory is already a file."""
|
|
84
|
+
|
|
85
|
+
current = destination.parent
|
|
86
|
+
while current != target_dir.parent:
|
|
87
|
+
if current.exists() and not current.is_dir():
|
|
88
|
+
raise GenerationError(f"Path segment is a file, not a directory: {current}")
|
|
89
|
+
if current == target_dir:
|
|
90
|
+
break
|
|
91
|
+
current = current.parent
|
tses/core/generator.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""High-level scaffold generation orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from importlib.abc import Traversable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tses.core.errors import GenerationError, UnsupportedFrameworkError
|
|
11
|
+
from tses.core.filesystem import PlannedAction, plan_actions, prepare_target_directory
|
|
12
|
+
from tses.core.metadata import GenerationMetadata, build_generation_metadata
|
|
13
|
+
from tses.core.renderer import apply_action
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SUPPORTED_FRAMEWORKS = {"django", "fastapi"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class GenerationResult:
|
|
21
|
+
"""Summary of one scaffold generation run."""
|
|
22
|
+
|
|
23
|
+
framework: str
|
|
24
|
+
original_target: Path
|
|
25
|
+
target_dir: Path
|
|
26
|
+
metadata: GenerationMetadata
|
|
27
|
+
actions: list[PlannedAction]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def created_count(self) -> int:
|
|
31
|
+
"""Return the number of created files."""
|
|
32
|
+
|
|
33
|
+
return sum(1 for action in self.actions if action.kind == "create")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def overwritten_count(self) -> int:
|
|
37
|
+
"""Return the number of overwritten files."""
|
|
38
|
+
|
|
39
|
+
return sum(1 for action in self.actions if action.kind == "overwrite")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_scaffold(
|
|
43
|
+
*,
|
|
44
|
+
framework: str,
|
|
45
|
+
target: Path,
|
|
46
|
+
force: bool = False,
|
|
47
|
+
dry_run: bool = False,
|
|
48
|
+
) -> GenerationResult:
|
|
49
|
+
"""Generate a vendored framework scaffold into the target directory."""
|
|
50
|
+
|
|
51
|
+
normalized_framework = framework.strip().lower()
|
|
52
|
+
template_root = get_template_root(normalized_framework)
|
|
53
|
+
|
|
54
|
+
target_dir = target.expanduser().resolve()
|
|
55
|
+
metadata = build_generation_metadata(
|
|
56
|
+
framework=normalized_framework,
|
|
57
|
+
target_dir=target_dir,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
prepare_target_directory(target_dir, force=force, dry_run=dry_run)
|
|
61
|
+
actions = plan_actions(template_root, target_dir)
|
|
62
|
+
|
|
63
|
+
if not dry_run:
|
|
64
|
+
for action in actions:
|
|
65
|
+
apply_action(action, metadata)
|
|
66
|
+
|
|
67
|
+
return GenerationResult(
|
|
68
|
+
framework=normalized_framework,
|
|
69
|
+
original_target=target,
|
|
70
|
+
target_dir=target_dir,
|
|
71
|
+
metadata=metadata,
|
|
72
|
+
actions=actions,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_template_root(framework: str) -> Traversable:
|
|
77
|
+
"""Return the vendored template root for a supported framework."""
|
|
78
|
+
|
|
79
|
+
if framework not in SUPPORTED_FRAMEWORKS:
|
|
80
|
+
raise UnsupportedFrameworkError(
|
|
81
|
+
f"Unsupported framework '{framework}'. Expected one of: django, fastapi."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
template_root = resources.files("tses.templates").joinpath(framework)
|
|
85
|
+
if not template_root.exists() or not template_root.is_dir():
|
|
86
|
+
raise GenerationError(
|
|
87
|
+
f"Vendored template for framework '{framework}' is missing. "
|
|
88
|
+
"Run tools/sync_scaffolds.py to refresh scaffold snapshots."
|
|
89
|
+
)
|
|
90
|
+
return template_root
|
tses/core/headers.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Generated-file header insertion helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tses.core.metadata import GenerationMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HASH_COMMENT_FILENAMES = {
|
|
11
|
+
".dockerignore",
|
|
12
|
+
".env",
|
|
13
|
+
".env.example",
|
|
14
|
+
".gitignore",
|
|
15
|
+
"Dockerfile",
|
|
16
|
+
"Makefile",
|
|
17
|
+
}
|
|
18
|
+
HASH_COMMENT_SUFFIXES = {
|
|
19
|
+
".conf",
|
|
20
|
+
".cfg",
|
|
21
|
+
".env",
|
|
22
|
+
".ini",
|
|
23
|
+
".py",
|
|
24
|
+
".pyi",
|
|
25
|
+
".requirements",
|
|
26
|
+
".sh",
|
|
27
|
+
".sql",
|
|
28
|
+
".toml",
|
|
29
|
+
".yaml",
|
|
30
|
+
".yml",
|
|
31
|
+
}
|
|
32
|
+
BLOCK_COMMENT_SUFFIXES = {".css", ".js", ".jsx", ".ts", ".tsx"}
|
|
33
|
+
HTML_COMMENT_SUFFIXES = {".html", ".htm", ".markdown", ".md", ".svg", ".xml"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inject_generated_header(
|
|
37
|
+
content: str,
|
|
38
|
+
relative_path: Path,
|
|
39
|
+
metadata: GenerationMetadata,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Inject a generated-file header when the file type supports comments."""
|
|
42
|
+
|
|
43
|
+
comment = _build_comment(relative_path, metadata.header_text)
|
|
44
|
+
if comment is None:
|
|
45
|
+
return content
|
|
46
|
+
|
|
47
|
+
insertion = f"{comment}\n"
|
|
48
|
+
if content.startswith("#!"):
|
|
49
|
+
return _insert_after_first_line(content, insertion)
|
|
50
|
+
if content.startswith("<?xml"):
|
|
51
|
+
return _insert_after_first_line(content, insertion)
|
|
52
|
+
return f"{insertion}{content}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_comment(relative_path: Path, text: str) -> str | None:
|
|
56
|
+
"""Return the correctly formatted comment string for a file path."""
|
|
57
|
+
|
|
58
|
+
name = relative_path.name
|
|
59
|
+
suffixes = set(relative_path.suffixes)
|
|
60
|
+
|
|
61
|
+
if name in HASH_COMMENT_FILENAMES or suffixes & HASH_COMMENT_SUFFIXES:
|
|
62
|
+
return f"# {text}"
|
|
63
|
+
if suffixes & BLOCK_COMMENT_SUFFIXES:
|
|
64
|
+
return f"/* {text} */"
|
|
65
|
+
if suffixes & HTML_COMMENT_SUFFIXES:
|
|
66
|
+
return f"<!-- {text} -->"
|
|
67
|
+
if name.startswith("requirements"):
|
|
68
|
+
return f"# {text}"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _insert_after_first_line(content: str, insertion: str) -> str:
|
|
73
|
+
"""Insert text immediately after the first line of a file."""
|
|
74
|
+
|
|
75
|
+
newline_index = content.find("\n")
|
|
76
|
+
if newline_index == -1:
|
|
77
|
+
return f"{content}\n{insertion}"
|
|
78
|
+
return f"{content[: newline_index + 1]}{insertion}{content[newline_index + 1:]}"
|
tses/core/metadata.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Metadata helpers for scaffold generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from tses.core.errors import GenerationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GenerationMetadata:
|
|
15
|
+
"""Context values associated with one generation run."""
|
|
16
|
+
|
|
17
|
+
framework: str
|
|
18
|
+
project_name: str
|
|
19
|
+
project_slug: str
|
|
20
|
+
target_dir: Path
|
|
21
|
+
generated_at: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def header_text(self) -> str:
|
|
25
|
+
"""Return the canonical generated-file header text."""
|
|
26
|
+
|
|
27
|
+
return f"Generated by Tses {self.generated_at}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_generation_metadata(*, framework: str, target_dir: Path) -> GenerationMetadata:
|
|
31
|
+
"""Build normalized metadata for a generation request."""
|
|
32
|
+
|
|
33
|
+
resolved_target = target_dir.resolve()
|
|
34
|
+
project_name = resolved_target.name.strip()
|
|
35
|
+
if not project_name:
|
|
36
|
+
raise GenerationError("Could not derive a project name from the target path.")
|
|
37
|
+
|
|
38
|
+
project_slug = slugify(project_name)
|
|
39
|
+
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
40
|
+
"+00:00", "Z"
|
|
41
|
+
)
|
|
42
|
+
return GenerationMetadata(
|
|
43
|
+
framework=framework,
|
|
44
|
+
project_name=project_name,
|
|
45
|
+
project_slug=project_slug,
|
|
46
|
+
target_dir=resolved_target,
|
|
47
|
+
generated_at=generated_at,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def slugify(value: str) -> str:
|
|
52
|
+
"""Normalize a directory-derived value into a simple slug."""
|
|
53
|
+
|
|
54
|
+
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
|
55
|
+
return normalized or "project"
|
tses/core/renderer.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Rendering and file-write helpers for scaffold generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tses.core.filesystem import PlannedAction
|
|
8
|
+
from tses.core.headers import inject_generated_header
|
|
9
|
+
from tses.core.metadata import GenerationMetadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def apply_action(action: PlannedAction, metadata: GenerationMetadata) -> None:
|
|
13
|
+
"""Render one planned file action to disk."""
|
|
14
|
+
|
|
15
|
+
raw_bytes = action.source.read_bytes()
|
|
16
|
+
rendered_bytes = render_bytes(
|
|
17
|
+
raw_bytes=raw_bytes,
|
|
18
|
+
relative_path=action.relative_path,
|
|
19
|
+
metadata=metadata,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
action.destination.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
action.destination.write_bytes(rendered_bytes)
|
|
24
|
+
_apply_permissions(action.destination, action.relative_path)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_bytes(
|
|
28
|
+
*,
|
|
29
|
+
raw_bytes: bytes,
|
|
30
|
+
relative_path: Path,
|
|
31
|
+
metadata: GenerationMetadata,
|
|
32
|
+
) -> bytes:
|
|
33
|
+
"""Render bytes for a generated file, injecting headers where allowed."""
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
text = raw_bytes.decode("utf-8")
|
|
37
|
+
except UnicodeDecodeError:
|
|
38
|
+
return raw_bytes
|
|
39
|
+
|
|
40
|
+
rendered_text = inject_generated_header(text, relative_path, metadata)
|
|
41
|
+
return rendered_text.encode("utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _apply_permissions(destination: Path, relative_path: Path) -> None:
|
|
45
|
+
"""Apply deterministic executable permissions for generated scripts."""
|
|
46
|
+
|
|
47
|
+
if relative_path.suffix == ".sh":
|
|
48
|
+
destination.chmod(destination.stat().st_mode | 0o111)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vendored scaffold templates packaged with TSES."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
4
|
+
PYTHONUNBUFFERED=1 \
|
|
5
|
+
DJANGO_SETTINGS_MODULE=config.settings
|
|
6
|
+
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
|
|
9
|
+
COPY requirements.txt /app/requirements.txt
|
|
10
|
+
|
|
11
|
+
RUN pip install --upgrade pip \
|
|
12
|
+
&& pip install -r /app/requirements.txt
|
|
13
|
+
|
|
14
|
+
COPY . /app
|
|
15
|
+
|
|
16
|
+
RUN chmod +x /app/.docker/entrypoint.sh
|
|
17
|
+
|
|
18
|
+
EXPOSE 8000
|
|
19
|
+
|
|
20
|
+
ENTRYPOINT ["/bin/sh", "/app/.docker/entrypoint.sh"]
|
|
21
|
+
|
|
22
|
+
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
python manage.py check
|
|
5
|
+
|
|
6
|
+
if [ "${DJANGO_RUN_MIGRATIONS:-1}" = "1" ]; then
|
|
7
|
+
python manage.py migrate --noinput
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# static collection opt-in because project may not define STATIC_ROOT yet.
|
|
11
|
+
if [ "${DJANGO_COLLECTSTATIC:-0}" = "1" ]; then
|
|
12
|
+
python manage.py collectstatic --noinput
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
exec "$@"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.git/
|
|
2
|
+
.agents/
|
|
3
|
+
.codex/
|
|
4
|
+
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*.sqlite3
|
|
12
|
+
.python-version
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
env/
|
|
16
|
+
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
htmlcov/
|
|
22
|
+
|
|
23
|
+
staticfiles/
|
|
24
|
+
media/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Django
|
|
2
|
+
DJANGO_SETTINGS_MODULE=config.settings
|
|
3
|
+
SECRET_KEY=change-me
|
|
4
|
+
DEBUG=True
|
|
5
|
+
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
6
|
+
|
|
7
|
+
# Database
|
|
8
|
+
DB_HOST=postgres
|
|
9
|
+
DB_PORT=5432
|
|
10
|
+
DB_NAME=postgres
|
|
11
|
+
DB_USER=postgres
|
|
12
|
+
DB_PASSWORD=postgres
|
|
13
|
+
|
|
14
|
+
# JWT
|
|
15
|
+
SIGNING_KEY=change-me
|
|
16
|
+
|
|
17
|
+
# Container startup
|
|
18
|
+
DJANGO_RUN_MIGRATIONS=1
|
|
19
|
+
DJANGO_COLLECTSTATIC=0
|
|
20
|
+
|
|
21
|
+
# Celery
|
|
22
|
+
CELERY_BROKER_URL=redis://redis:6379/0
|
|
23
|
+
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
|
24
|
+
|
|
25
|
+
# Email
|
|
26
|
+
EMAIL_HOST=localhost
|
|
27
|
+
EMAIL_PORT=25
|
|
28
|
+
EMAIL_USE_SSL=False
|
|
29
|
+
EMAIL_USE_TLS=False
|
|
30
|
+
EMAIL_HOST_USER=
|
|
31
|
+
EMAIL_HOST_PASSWORD=
|
|
32
|
+
DEFAULT_FROM_EMAIL=no-reply@example.com
|