diff --git a/.gitignore b/.gitignore index 2b5243c..ef0d5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ #--------------------------------------------------# # The following was generated by djinit: # #--------------------------------------------------# +AGENTS.md # Python *.py[cod] diff --git a/djinit/commands/handler.py b/djinit/commands/handler.py deleted file mode 100644 index 5e84f34..0000000 --- a/djinit/commands/handler.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Command handlers for Django project setup CLI. -Handles different commands like secret key generation and app creation. -""" - -import argparse -import sys - -from rich.panel import Panel -from rich.text import Text - -from djinit.services.app import AppManager -from djinit.ui.console import UIColors, UIFormatter, console -from djinit.utils.secretkey import display_secret_keys, generate_multiple_keys -from djinit.utils.validators import validate_app_name - - -def parse_arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Django project setup tool", prog="django-init") - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - setup_parser = subparsers.add_parser("setup", help="Create a new Django project") - setup_parser.add_argument("--project", help="Django project name") - setup_parser.add_argument("--app", help="Django app name") - - secret_parser = subparsers.add_parser("secret", help="Generate Django secret keys") - secret_parser.add_argument("--count", type=int, default=3, help="Number of keys to generate") - secret_parser.add_argument("--length", type=int, default=50, help="Length of each secret key") - - app_parser = subparsers.add_parser("app", help="Create one or more Django apps (comma or space separated)") - app_parser.add_argument( - "app_name", - nargs="+", - help=( - "App name(s) or comma-separated list. Examples: \n" - " djinit app users \n" - " djinit app users,products,orders \n" - " djinit app users products orders \n" - " djinit app users, products, orders" - ), - ) - - return parser.parse_args() - - -def handle_secret_command(args: argparse.Namespace) -> None: - keys = generate_multiple_keys(args.count, args.length) - UIFormatter.print_info("") - display_secret_keys(keys) - - instructions = Text() - instructions.append("📋 Usage Instructions:\n", style=UIColors.ACCENT) - instructions.append("1. Copy the appropriate secret key for your environment\n") - instructions.append("2. Add it to your .env file:\n") - instructions.append(" SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE) - instructions.append("3. Or set it as an environment variable:\n") - instructions.append(" export SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE) - instructions.append("4. Never commit secret keys to version control!\n", style=UIColors.WARNING) - - console.print(Panel(instructions, title="💡 How to Use", border_style="blue")) - console.print() - - -def handle_app_command(args: argparse.Namespace) -> None: - UIFormatter.print_info("") - UIFormatter.print_header("Django App Creation") - UIFormatter.print_info("") - - raw_tokens = args.app_name if isinstance(args.app_name, list) else [args.app_name] - - # Flatten and clean tokens - app_names = [part.strip() for token in raw_tokens if token for part in str(token).split(",") if part.strip()] - - if not app_names: - UIFormatter.print_error("App name cannot be empty") - sys.exit(1) - - # Deduplicate while preserving order - deduped_names = list(dict.fromkeys(app_names)) - - for name in deduped_names: - is_valid, error_msg = validate_app_name(name) - if not is_valid: - UIFormatter.print_error(f"Invalid app name '{name}': {error_msg}") - sys.exit(1) - - any_failure = False - for name in deduped_names: - app_manager = AppManager(name) - success = app_manager.create_app() - if not success: - UIFormatter.print_error(f"Failed to create Django app '{name}'") - any_failure = True - break - UIFormatter.print_success(f"Django app '{name}' created successfully!") - - if any_failure: - sys.exit(1) - - instructions = Text() - instructions.append("🚀 Next Steps:\n", style=UIColors.ACCENT) - instructions.append("1. The app(s) have been added to INSTALLED_APPS in settings/base.py\n") - instructions.append("2. Create your models in each app's models.py\n") - instructions.append("3. Run migrations: just makemigrations\n") - instructions.append("4. Apply migrations: just migrate\n") - instructions.append("5. Create views, serializers and routes(URLs) for your app(s)\n") - - console.print(Panel(instructions, title="💡 What's Next", border_style="green")) - UIFormatter.print_info("") diff --git a/djinit/generator.py b/djinit/generator.py deleted file mode 100644 index 16779ac..0000000 --- a/djinit/generator.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Main script entry point for djinit. -Handles user input validation and orchestrates the setup process. -""" - -import os -import subprocess -import sys - -from djinit.cli import Cli -from djinit.commands.handler import handle_app_command, handle_secret_command, parse_arguments -from djinit.ui.console import UIFormatter, console -from djinit.ui.input import confirm_setup, get_user_input - - -def clear_screen() -> None: - try: - if os.name == "nt": - subprocess.run("cls", shell=True, check=True) - else: - subprocess.run("clear", shell=True, check=True) - except subprocess.CalledProcessError: - console.print("\n" * 50) - - -def main() -> None: - args = parse_arguments() - - if args.command == "secret": - handle_secret_command(args) - return - - if args.command == "app": - handle_app_command(args) - return - - clear_screen() - - console.print() - console.print(UIFormatter.create_welcome_panel()) - console.print() - - project_dir, project_name, primary_app, app_names, metadata = get_user_input() - - if not confirm_setup(project_dir, project_name, app_names, metadata): - UIFormatter.print_info("Setup cancelled by user.") - return - - console.print() - UIFormatter.print_info("Starting Django project setup...") - console.print() - - django_cli = Cli(project_dir, project_name, primary_app, app_names, metadata) - success = django_cli.run_setup() - - UIFormatter.create_summary_panel(project_dir, project_name, app_names, True, metadata=metadata) - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/djinit/templates/components/apps.j2 b/djinit/templates/components/apps.j2 deleted file mode 100644 index bc3adcc..0000000 --- a/djinit/templates/components/apps.j2 +++ /dev/null @@ -1,8 +0,0 @@ -"""generated with djinit""" - -from django.apps import AppConfig - - -class {{ app_name|title }}Config(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = '{{ app_name }}' diff --git a/djinit/templates/components/migrations_init.j2 b/djinit/templates/components/migrations_init.j2 deleted file mode 100644 index e69de29..0000000 diff --git a/djinit/templates/components/routes.j2 b/djinit/templates/components/routes.j2 deleted file mode 100644 index 3831c00..0000000 --- a/djinit/templates/components/routes.j2 +++ /dev/null @@ -1,18 +0,0 @@ -""" -generated with djinit - -API routes for {{ app_name }} app. -""" - -from rest_framework.routers import DefaultRouter -from . import views - -# Create router instance -router = DefaultRouter() - -# Register viewsets with router -# Example: -# router.register(r'{{ app_name }}', views.{{ app_name|title }}ViewSet) - -# Get URL patterns -urlpatterns = router.urls diff --git a/djinit/templates/components/serializers.j2 b/djinit/templates/components/serializers.j2 deleted file mode 100644 index e4b115d..0000000 --- a/djinit/templates/components/serializers.j2 +++ /dev/null @@ -1,14 +0,0 @@ -""" -generated with djinit -serializers for {{ app_name }} app. -""" - -from rest_framework import serializers - - -# Add your serializers here -# Example: -# class {{ app_name|title }}Serializer(serializers.ModelSerializer): -# class Meta: -# model = YourModel -# fields = '__all__' diff --git a/djinit/templates/config/urls/base.j2 b/djinit/templates/config/urls/base.j2 deleted file mode 100644 index ab41ddb..0000000 --- a/djinit/templates/config/urls/base.j2 +++ /dev/null @@ -1,8 +0,0 @@ -"""generated with djinit""" - -from django.contrib import admin -from django.urls import path - -urlpatterns = [ - path('admin/', admin.site.urls), -] diff --git a/djinit/templates/config/urls/with_api.j2 b/djinit/templates/config/urls/with_api.j2 deleted file mode 100644 index d0f0202..0000000 --- a/djinit/templates/config/urls/with_api.j2 +++ /dev/null @@ -1,30 +0,0 @@ -"""generated with djinit""" - -from django.contrib import admin -from django.urls import path, include -from django.conf import settings -from drf_spectacular.views import ( - SpectacularAPIView, - SpectacularSwaggerView, -) -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - TokenBlacklistView, -) - -urlpatterns = [ - path("admin/", admin.site.urls), - # JWT tokens - path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), - path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), - {% if comment_out_api_url %}# {% endif %}path("api/", include("{{ api_module }}.urls")), -] - -# Don't show schema in production -if settings.DEBUG: - urlpatterns += [ - path("schema/", SpectacularAPIView.as_view(), name="schema"), - path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), - ] diff --git a/djinit/templates/config/urls/with_apps.j2 b/djinit/templates/config/urls/with_apps.j2 deleted file mode 100644 index a299544..0000000 --- a/djinit/templates/config/urls/with_apps.j2 +++ /dev/null @@ -1,31 +0,0 @@ -"""generated with djinit""" - -from django.contrib import admin -from django.urls import path, include -from django.conf import settings -from drf_spectacular.views import ( - SpectacularAPIView, - SpectacularSwaggerView, -) -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - TokenBlacklistView, -) - -urlpatterns = [ - path("admin/", admin.site.urls), - # JWT tokens - path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), - path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), - # App URLs -{% if comment_out_api_url %}# {% endif %} {% for app in app_names %} path("", include("{{ app }}.urls")), -{% endfor %}] - -# Don't show schema in production -if settings.DEBUG: - urlpatterns += [ - path("schema/", SpectacularAPIView.as_view(), name="schema"), - path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), - ] diff --git a/djinit/templates/presets/predefined/api/urls.j2 b/djinit/templates/presets/predefined/api/urls.j2 deleted file mode 100644 index 51e3dfb..0000000 --- a/djinit/templates/presets/predefined/api/urls.j2 +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import include, path - -app_name = "api" - -urlpatterns = [ - path("v1/", include("api.v1.urls")), -] diff --git a/djinit/templates/presets/predefined/api/v1/urls.j2 b/djinit/templates/presets/predefined/api/v1/urls.j2 deleted file mode 100644 index 7a0d9a8..0000000 --- a/djinit/templates/presets/predefined/api/v1/urls.j2 +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import include, path - -app_name = "v1" - -urlpatterns = [ - path("users/", include("apps.users.urls")), -] diff --git a/djinit/templates/presets/predefined/apps/generic/models.j2 b/djinit/templates/presets/predefined/apps/generic/models.j2 deleted file mode 100644 index 9f99a46..0000000 --- a/djinit/templates/presets/predefined/apps/generic/models.j2 +++ /dev/null @@ -1,8 +0,0 @@ -from django.db import models - - -class {{ model_class_name }}(models.Model): - """Generated model for {{ app_name }} app.""" - - # Add fields here - pass diff --git a/djinit/templates/presets/predefined/apps/generic/serializers.j2 b/djinit/templates/presets/predefined/apps/generic/serializers.j2 deleted file mode 100644 index 86f1b45..0000000 --- a/djinit/templates/presets/predefined/apps/generic/serializers.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} - - -class {{ model_class_name }}Serializer(serializers.ModelSerializer): - class Meta: - model = {{ model_class_name }} - fields = "__all__" diff --git a/djinit/templates/presets/predefined/apps/generic/services.j2 b/djinit/templates/presets/predefined/apps/generic/services.j2 deleted file mode 100644 index 2203fb8..0000000 --- a/djinit/templates/presets/predefined/apps/generic/services.j2 +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Optional - -from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} - - -def get_{{ app_name }}_by_id(object_id: int) -> Optional[{{ model_class_name }}]: - try: - return {{ model_class_name }}.objects.get(id=object_id) - except {{ model_class_name }}.DoesNotExist: - return None diff --git a/djinit/templates/presets/predefined/apps/generic/tests.j2 b/djinit/templates/presets/predefined/apps/generic/tests.j2 deleted file mode 100644 index 41fc906..0000000 --- a/djinit/templates/presets/predefined/apps/generic/tests.j2 +++ /dev/null @@ -1,12 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework.test import APIClient - - -@pytest.mark.django_db -def test_{{ app_name }}_list_empty(): - client = APIClient() - url = reverse("v1:{{ app_name }}-list") - response = client.get(url) - assert response.status_code == 200 - assert response.json() == [] diff --git a/djinit/templates/presets/predefined/apps/generic/urls.j2 b/djinit/templates/presets/predefined/apps/generic/urls.j2 deleted file mode 100644 index caec1a6..0000000 --- a/djinit/templates/presets/predefined/apps/generic/urls.j2 +++ /dev/null @@ -1,14 +0,0 @@ -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from {{ app_module }}.views.{{ app_name }}_view import {{ model_class_name }}ViewSet - - -app_name = "{{ app_name }}" - -router = DefaultRouter() -router.register(r"{{ app_name }}", {{ model_class_name }}ViewSet, basename="{{ app_name }}") - -urlpatterns = [ - path("", include(router.urls)), -] diff --git a/djinit/templates/presets/predefined/apps/generic/views.j2 b/djinit/templates/presets/predefined/apps/generic/views.j2 deleted file mode 100644 index 5976564..0000000 --- a/djinit/templates/presets/predefined/apps/generic/views.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import viewsets - -from {{ app_module }}.models.{{ app_name }} import {{ model_class_name }} -from {{ app_module }}.serializers.{{ app_name }}_serializer import {{ model_class_name }}Serializer - - -class {{ model_class_name }}ViewSet(viewsets.ModelViewSet): - queryset = {{ model_class_name }}.objects.all() - serializer_class = {{ model_class_name }}Serializer diff --git a/djinit/templates/presets/predefined/apps/users/models.j2 b/djinit/templates/presets/predefined/apps/users/models.j2 deleted file mode 100644 index f6236bd..0000000 --- a/djinit/templates/presets/predefined/apps/users/models.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from django.contrib.auth.models import AbstractUser -from django.db import models - - -class User(AbstractUser): - """Custom user model extending Django's AbstractUser.""" - - # Add custom fields here if needed - pass diff --git a/djinit/templates/presets/predefined/apps/users/serializers.j2 b/djinit/templates/presets/predefined/apps/users/serializers.j2 deleted file mode 100644 index d7a4f40..0000000 --- a/djinit/templates/presets/predefined/apps/users/serializers.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -from apps.users.models.user import User - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["id", "username", "email", "first_name", "last_name"] diff --git a/djinit/templates/presets/predefined/apps/users/services.j2 b/djinit/templates/presets/predefined/apps/users/services.j2 deleted file mode 100644 index 036060b..0000000 --- a/djinit/templates/presets/predefined/apps/users/services.j2 +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Optional - -from apps.users.models.user import User - - -def get_user_by_id(user_id: int) -> Optional[User]: - try: - return User.objects.get(id=user_id) - except User.DoesNotExist: - return None diff --git a/djinit/templates/presets/predefined/apps/users/tests.j2 b/djinit/templates/presets/predefined/apps/users/tests.j2 deleted file mode 100644 index 1bde766..0000000 --- a/djinit/templates/presets/predefined/apps/users/tests.j2 +++ /dev/null @@ -1,12 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework.test import APIClient - - -@pytest.mark.django_db -def test_users_list_empty(): - client = APIClient() - url = reverse("v1:users-list") - response = client.get(url) - assert response.status_code == 200 - assert response.json() == [] diff --git a/djinit/templates/presets/predefined/apps/users/urls.j2 b/djinit/templates/presets/predefined/apps/users/urls.j2 deleted file mode 100644 index 08a6fb4..0000000 --- a/djinit/templates/presets/predefined/apps/users/urls.j2 +++ /dev/null @@ -1,14 +0,0 @@ -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from apps.users.views.user_view import UserViewSet - - -app_name = "users" - -router = DefaultRouter() -router.register(r"users", UserViewSet, basename="users") - -urlpatterns = [ - path("", include(router.urls)), -] diff --git a/djinit/templates/presets/predefined/apps/users/views.j2 b/djinit/templates/presets/predefined/apps/users/views.j2 deleted file mode 100644 index 8db2347..0000000 --- a/djinit/templates/presets/predefined/apps/users/views.j2 +++ /dev/null @@ -1,13 +0,0 @@ -from rest_framework import status, viewsets -from rest_framework.response import Response - -from apps.users.models.user import User -from apps.users.serializers.user_serializer import UserSerializer - - -class UserViewSet(viewsets.ReadOnlyModelViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) diff --git a/djinit/templates/presets/predefined/core/exceptions.j2 b/djinit/templates/presets/predefined/core/exceptions.j2 deleted file mode 100644 index bb426d3..0000000 --- a/djinit/templates/presets/predefined/core/exceptions.j2 +++ /dev/null @@ -1,5 +0,0 @@ -class ApplicationError(Exception): - """Base application exception.""" - - def __init__(self, message: str): - super().__init__(message) diff --git a/djinit/templates/presets/predefined/core/middleware/request_logger.j2 b/djinit/templates/presets/predefined/core/middleware/request_logger.j2 deleted file mode 100644 index d922ad1..0000000 --- a/djinit/templates/presets/predefined/core/middleware/request_logger.j2 +++ /dev/null @@ -1,14 +0,0 @@ -import logging - - -logger = logging.getLogger(__name__) - - -class RequestLoggerMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - logger.info("%s %s", request.method, request.path) - response = self.get_response(request) - return response diff --git a/djinit/templates/presets/predefined/core/mixins/timestamped_model.j2 b/djinit/templates/presets/predefined/core/mixins/timestamped_model.j2 deleted file mode 100644 index 6b2e70e..0000000 --- a/djinit/templates/presets/predefined/core/mixins/timestamped_model.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from django.db import models - - -class TimestampedModel(models.Model): - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True diff --git a/djinit/templates/presets/predefined/core/utils/responses.j2 b/djinit/templates/presets/predefined/core/utils/responses.j2 deleted file mode 100644 index bae79a1..0000000 --- a/djinit/templates/presets/predefined/core/utils/responses.j2 +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.response import Response - - -def success(data=None, status_code=200): - return Response({"success": True, "data": data}, status=status_code) - - -def error(message, status_code=400): - return Response({"success": False, "message": message}, status=status_code) diff --git a/djinit/templates/presets/unified/apps/core/apps.j2 b/djinit/templates/presets/unified/apps/core/apps.j2 deleted file mode 100644 index 1d202ca..0000000 --- a/djinit/templates/presets/unified/apps/core/apps.j2 +++ /dev/null @@ -1,8 +0,0 @@ -"""generated with djinit""" - -from django.apps import AppConfig - - -class CoreConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "apps.core" diff --git a/djinit/templates/presets/unified/apps/core/models/__init__.py.j2 b/djinit/templates/presets/unified/apps/core/models/__init__.py.j2 deleted file mode 100644 index 89b2eb6..0000000 --- a/djinit/templates/presets/unified/apps/core/models/__init__.py.j2 +++ /dev/null @@ -1,4 +0,0 @@ -from .base import AbstractBaseModel -from .mixins import TimestampedModel - -__all__ = ["AbstractBaseModel", "TimestampedModel"] diff --git a/djinit/templates/presets/unified/apps/core/models/base.py.j2 b/djinit/templates/presets/unified/apps/core/models/base.py.j2 deleted file mode 100644 index a3970f9..0000000 --- a/djinit/templates/presets/unified/apps/core/models/base.py.j2 +++ /dev/null @@ -1,13 +0,0 @@ -"""generated with djinit""" - -from django.db import models - - -class AbstractBaseModel(models.Model): - """Abstract base model with common fields.""" - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True diff --git a/djinit/templates/presets/unified/apps/core/models/mixins.py.j2 b/djinit/templates/presets/unified/apps/core/models/mixins.py.j2 deleted file mode 100644 index fe40544..0000000 --- a/djinit/templates/presets/unified/apps/core/models/mixins.py.j2 +++ /dev/null @@ -1,13 +0,0 @@ -"""generated with djinit""" - -from django.db import models - - -class TimestampedModel(models.Model): - """Mixin for models that need timestamp fields.""" - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True diff --git a/djinit/templates/presets/unified/apps/core/utils/__init__.py.j2 b/djinit/templates/presets/unified/apps/core/utils/__init__.py.j2 deleted file mode 100644 index 155f5b4..0000000 --- a/djinit/templates/presets/unified/apps/core/utils/__init__.py.j2 +++ /dev/null @@ -1,3 +0,0 @@ -from .responses import success, error - -__all__ = ["success", "error"] diff --git a/djinit/templates/presets/unified/apps/core/utils/responses.py.j2 b/djinit/templates/presets/unified/apps/core/utils/responses.py.j2 deleted file mode 100644 index 929c2f6..0000000 --- a/djinit/templates/presets/unified/apps/core/utils/responses.py.j2 +++ /dev/null @@ -1,13 +0,0 @@ -"""generated with djinit""" - -from rest_framework.response import Response - - -def success(data=None, status_code=200): - """Return a successful response.""" - return Response({"success": True, "data": data}, status=status_code) - - -def error(message, status_code=400): - """Return an error response.""" - return Response({"success": False, "message": message}, status=status_code) diff --git a/djinit/templates/project/init.j2 b/djinit/templates/project/init.j2 deleted file mode 100644 index e69de29..0000000 diff --git a/djinit/templates/project/runtime_txt.j2 b/djinit/templates/project/runtime_txt.j2 deleted file mode 100644 index 7cfeb51..0000000 --- a/djinit/templates/project/runtime_txt.j2 +++ /dev/null @@ -1 +0,0 @@ -python-{{ python_version }} diff --git a/djinit/utils/common.py b/djinit/utils/common.py deleted file mode 100644 index eb9e13e..0000000 --- a/djinit/utils/common.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Shared utility functions. -Contains common operations used across multiple managers. -""" - -import os -import subprocess -import sys -from contextlib import contextmanager - -from djinit.services.templates import template_engine -from djinit.ui.console import UIFormatter - - -def format_file(filename: str) -> None: - """Format Python file using Ruff formatter.""" - subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) - - -def create_file_with_content(filename: str, content: str, success_message: str, should_format: bool = False) -> None: - from djinit.utils.exceptions import FileError - - try: - with open(filename, "w") as file: - file.write(content) - except OSError as e: - raise FileError(f"Failed to write file: {filename}", details=str(e)) from e - - if should_format: - format_file(filename) - - UIFormatter.print_success(success_message) - - -def create_file_from_template( - file_path: str, - template_path: str, - context: dict, - success_message: str, - should_format: bool = True, -) -> None: - try: - content = template_engine.render_template(template_path, context) - except Exception as e: - from djinit.utils.exceptions import TemplateError - - raise TemplateError(f"Failed to render template: {template_path}", details=str(e)) from e - - create_file_with_content(file_path, content, success_message, should_format=should_format) - - -def create_init_file(directory: str, success_message: str) -> None: - init_path = os.path.join(directory, "__init__.py") - create_file_from_template(init_path, "project/init.j2", {}, success_message, should_format=False) - - -def create_directory_with_init(dir_path: str, message: str) -> None: - """Create directory and __init__.py file.""" - os.makedirs(dir_path, exist_ok=True) - create_init_file(dir_path, message) - - -def create_files_from_templates(base_dir: str, files: list, prefix: str = "", should_format: bool = False) -> None: - """Create multiple files from templates. - - Args: - base_dir: Base directory where files will be created - files: List of tuples (filename, template_path, context) - prefix: Optional prefix for success message - should_format: Whether to format the created files - """ - for file_spec in files: - if len(file_spec) == 3: - filename, template_path, context = file_spec - else: - # Support (filename, template_path) format with empty context - filename, template_path = file_spec - context = {} - filepath = os.path.join(base_dir, filename) - message = f"Created {prefix}{filename}" if prefix else f"Created {filename}" - create_file_from_template(filepath, template_path, context, message, should_format=should_format) - - -def get_package_name(project_dir: str) -> str: - """Get package name, defaulting to 'backend' if project_dir is '.' or empty.""" - return "backend" if project_dir == "." or not project_dir else project_dir - - -@contextmanager -def change_cwd(path: str): - original_cwd = os.getcwd() - os.chdir(path) - try: - yield - finally: - os.chdir(original_cwd) - - -def _parse_app_entry_from_line(line: str) -> str | None: - """Extract app name from a line in USER_DEFINED_APPS section.""" - line_stripped = line.strip().strip(",").strip() - for quote in ('"', "'"): - if line_stripped.startswith(quote) and line_stripped.endswith(quote): - return line_stripped.strip(quote) - return None - - -def _is_user_apps_section_start(line: str) -> bool: - """Check if line starts USER_DEFINED_APPS section.""" - return "USER_DEFINED_APPS" in line and "=" in line - - -def _iterate_user_apps_lines(content: str): - """Generator that yields lines within USER_DEFINED_APPS section.""" - lines = content.split("\n") - in_user_apps = False - - for line in lines: - if _is_user_apps_section_start(line): - in_user_apps = True - elif in_user_apps and line.strip() == "]": - break - elif in_user_apps: - yield line - - -def extract_existing_apps(content: str) -> set: - existing_apps = set() - for line in _iterate_user_apps_lines(content): - app_name = _parse_app_entry_from_line(line) - if app_name: - existing_apps.add(app_name) - return existing_apps - - -def format_app_entries(apps: list) -> list: - return [f' "{app}",' for app in apps] - - -def is_app_section_start(line: str) -> bool: - return "=" in line and any(keyword in line for keyword in ("BUILT_IN_APPS", "THIRD_PARTY_APPS", "INSTALLED_APPS")) - - -def is_user_apps_closing_bracket(lines: list, current_idx: int) -> bool: - for next_idx in range(current_idx + 1, min(current_idx + 3, len(lines))): - next_line = lines[next_idx].strip() - if next_line and "BUILT_IN_APPS" in next_line: - return True - return True # Default to True if we're inside USER_DEFINED_APPS - - -def split_bracket_line(line: str, apps_to_add: list) -> list: - bracket_pos = line.rfind("]") - before_bracket = line[:bracket_pos].rstrip() if bracket_pos > 0 else line.rstrip() - after_bracket = line[bracket_pos:] if bracket_pos > 0 else "]" - - result = [before_bracket] - result.extend(format_app_entries(apps_to_add)) - result.append(after_bracket) - return result - - -def insert_apps_into_user_defined_apps(content: str, apps_to_add: list) -> str: - lines = content.split("\n") - new_lines = [] - in_user_apps = False - apps_added = False - - for i, line in enumerate(lines): - if _is_user_apps_section_start(line) and not apps_added: - in_user_apps = True - - if "]" in line: - new_lines.extend(split_bracket_line(line, apps_to_add)) - apps_added = True - in_user_apps = False - else: - new_lines.append(line) - - elif in_user_apps and line.strip() == "]" and not apps_added: - if is_user_apps_closing_bracket(lines, i): - new_lines.extend(format_app_entries(apps_to_add)) - new_lines.append(line) - apps_added = True - in_user_apps = False - else: - new_lines.append(line) - - elif in_user_apps and is_app_section_start(line) and not apps_added: - new_lines.extend(format_app_entries(apps_to_add)) - new_lines.append("]") - new_lines.append(line) - apps_added = True - in_user_apps = False - - else: - new_lines.append(line) - - if not apps_added: - UIFormatter.print_error("Could not find or update USER_DEFINED_APPS section in base.py") - return None - - return "\n".join(new_lines) - - -def calculate_app_module_paths(app_names: list, metadata: dict) -> list: - nested = bool(metadata.get("nested_apps")) - nested_dir_name = metadata.get("nested_dir") if nested else None - return [calculate_app_module_path(app_name, nested, nested_dir_name) for app_name in app_names] - - -def calculate_app_module_path(app_name: str, nested: bool, nested_dir: str | None) -> str: - return f"{nested_dir}.{app_name}" if nested and nested_dir else app_name - - -def is_django_project(directory: str = None) -> bool: - if directory is None: - directory = os.getcwd() - manage_py_path = os.path.join(directory, "manage.py") - return os.path.exists(manage_py_path) - - -def find_project_dir(search_dir: str = None) -> tuple[str | None, str | None]: - if search_dir is None: - search_dir = os.getcwd() - - for item in os.listdir(search_dir): - if os.path.isdir(item) and not item.startswith(".") and item != "__pycache__": - candidate = os.path.join(search_dir, item) - base_py = os.path.join(candidate, "settings", "base.py") - if os.path.exists(base_py): - return candidate, base_py - return None, None - - -def find_settings_path(search_dir: str = None) -> str | None: - project_dir, settings_base_path = find_project_dir(search_dir) - if settings_base_path: - return os.path.dirname(settings_base_path) - return None - - -def get_base_settings_path(project_root: str, project_name: str) -> str: - settings_dir = os.path.join(project_root, project_name, "settings") - return os.path.join(settings_dir, "base.py") - - -def read_base_settings(project_root: str, project_name: str) -> str | None: - base_settings_path = get_base_settings_path(project_root, project_name) - if not os.path.exists(base_settings_path): - return None - - with open(base_settings_path) as f: - return f.read() - - -def detect_nested_structure_from_settings( - base_settings_path: str, search_dir: str = None -) -> tuple[bool, str | None, str]: - if search_dir is None: - search_dir = os.getcwd() - - if not os.path.exists(base_settings_path): - return False, None, search_dir - - try: - with open(base_settings_path) as f: - base_content = f.read() - - for line in _iterate_user_apps_lines(base_content): - app_label = _parse_app_entry_from_line(line) - if app_label and "." in app_label: - nested_dir_name = app_label.split(".", 1)[0] - nested_path = os.path.join(search_dir, nested_dir_name) - if os.path.isdir(nested_path): - return True, nested_dir_name, nested_path - except Exception: - pass - - return False, None, search_dir - - -def get_djinit_config(project_root: str = None) -> dict | None: - """Read .djinit configuration file.""" - import json - - if project_root is None: - project_root = os.getcwd() - - config_path = os.path.join(project_root, ".djinit") - if not os.path.exists(config_path): - # Try finding it in parent directories - parent = os.path.dirname(project_root) - if parent and parent != project_root: - return get_djinit_config(parent) - return None - - try: - with open(config_path) as f: - return json.load(f) - except Exception: - return None diff --git a/djinit/utils/django.py b/djinit/utils/django.py deleted file mode 100644 index f893210..0000000 --- a/djinit/utils/django.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Django helper module that replicates Django's startproject and startapp functionality. - -This module provides functions to create Django projects and apps without requiring Django. -""" - -import os - -from djinit.core.config import DJANGO_VERSION -from djinit.utils.common import ( - create_directory_with_init, - create_file_from_template, - create_files_from_templates, - create_init_file, -) - - -class DjangoHelper: - DJANGO_VERSION = DJANGO_VERSION - - @staticmethod - def startproject(project_name: str, directory: str, unified: bool = False) -> None: - try: - os.makedirs(directory, exist_ok=True) - - manage_py_path = os.path.join(directory, "manage.py") - create_file_from_template( - manage_py_path, "project/manage_py.j2", {"project_name": project_name}, "Created manage.py" - ) - os.chmod(manage_py_path, 0o755) - - if unified: - return - - project_config_dir = os.path.join(directory, project_name) - create_directory_with_init(project_config_dir, f"Created {project_name}/__init__.py") - - settings_dir = os.path.join(project_config_dir, "settings") - create_directory_with_init(settings_dir, f"Created {project_name}/settings/__init__.py") - - base_context = {"project_name": project_name, "app_names": []} - settings_files = [ - ("base.py", "config/settings/base.j2", base_context), - ("development.py", "config/settings/development.j2", {}), - ("production.py", "config/settings/production.j2", {}), - ] - create_files_from_templates(settings_dir, settings_files, f"{project_name}/settings/") - - urls_context = {"project_name": project_name, "django_version": DjangoHelper.DJANGO_VERSION} - project_files = [ - ("urls.py", "config/urls/base.j2", urls_context), - ("wsgi.py", "config/wsgi.j2", {}), - ("asgi.py", "config/asgi.j2", {}), - ] - create_files_from_templates(project_config_dir, project_files, f"{project_name}/") - - except Exception as e: - from djinit.utils.exceptions import DjinitError - - raise DjinitError(f"Error creating Django project: {e}", details=str(e)) from e - - @staticmethod - def startapp(app_name: str, directory: str) -> None: - try: - app_dir = os.path.join(directory, app_name) - os.makedirs(app_dir, exist_ok=True) - - context = {"app_name": app_name, "django_version": DjangoHelper.DJANGO_VERSION} - - create_init_file(app_dir, f"Created {app_name}/__init__.py") - - app_files = [ - ("apps.py", "components/apps.j2", context), - ("models.py", "components/models.j2", context), - ("views.py", "components/views.j2", context), - ("admin.py", "components/admin.j2", context), - ("urls.py", "components/urls.j2", context), - ("serializers.py", "components/serializers.j2", context), - ("routes.py", "components/routes.j2", context), - ("tests.py", "components/tests.j2", context), - ] - - create_files_from_templates(app_dir, app_files, f"{app_name}/") - - migrations_dir = os.path.join(app_dir, "migrations") - create_directory_with_init(migrations_dir, f"Created {app_name}/migrations/__init__.py") - - except Exception as e: - from djinit.utils.exceptions import DjinitError - - raise DjinitError(f"Error creating Django app: {e}", details=str(e)) from e diff --git a/pyproject.toml b/pyproject.toml index bf11c18..d93267d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["djinit"] +packages = ["src/djinit"] [project] name = "djinitx" @@ -48,16 +48,16 @@ classifiers=[ ] dependencies = [ "rich>=14.2.0", - "jinja2>=3.1.0", "questionary>=2.0.1", + "typer>=0.21.1", ] [project.urls] Homepage = "https://github.com/S4NKALP/djinit" [project.scripts] -djinit = "djinit.generator:main" -dj = "djinit.generator:main" +djinit = "djinit.cli:app" +dj = "djinit.cli:app" [dependency-groups] dev = [ diff --git a/requirements.txt b/requirements.txt index 338062a..ff60128 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -jinja2==3.1.6 questionary==2.1.1 rich==14.3.3 # dev diff --git a/ruff.toml b/ruff.toml index 9adb00f..c693f21 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,6 +2,7 @@ line-length = 120 target-version = "py38" +exclude = ["django-new"] [lint] select = [ diff --git a/setup.py b/setup.py index f782abb..49fb89f 100644 --- a/setup.py +++ b/setup.py @@ -9,19 +9,18 @@ setup( author="Sankalp Tharu", author_email="sankalptharu50028@gmail.com", url="https://github.com/S4NKALP/djinit", - packages=find_packages(include=["djinit", "djinit.*"]), + packages=find_packages(where="src"), + package_dir={"": "src"}, include_package_data=True, install_requires=[ - "ruff>=0.14.2", - "click>=8.3.0", "rich>=14.2.0", - "jinja2>=3.1.0", "questionary>=2.0.1", + "typer>=0.21.1", ], entry_points={ "console_scripts": [ - "djinit=djinit.generator:main", - "dj=djinit.generator:main", + "djinit=djinit.cli:app", + "dj=djinit.cli:app", ] }, classifiers=[ @@ -55,7 +54,7 @@ setup( "django project", "django starter", ], - python_requires=">=3.13", + python_requires=">=3.10", project_urls={ "Bug Reports": "https://github.com/S4NKALP/djinit/issues", "Source": "https://github.com/S4NKALP/djinit", diff --git a/djinit/__init__.py b/src/djinit/__init__.py similarity index 100% rename from djinit/__init__.py rename to src/djinit/__init__.py diff --git a/src/djinit/cli.py b/src/djinit/cli.py new file mode 100644 index 0000000..4f33abd --- /dev/null +++ b/src/djinit/cli.py @@ -0,0 +1,158 @@ +import logging +from typing import Annotated, Optional + +import typer +from rich.panel import Panel +from rich.text import Text + +from djinit.creators.app import AppCreator +from djinit.creators.setup import SetupCreator +from djinit.ui.console import UIColors, UIFormatter, console +from djinit.ui.input import get_user_input +from djinit.utils.secretkey import display_secret_keys, generate_multiple_keys +from djinit.utils.validators import validate_app_name + +# Configure logger +logger = logging.getLogger(__name__) + +app = typer.Typer( + help="djinit - A CLI tool to set up Django projects with best practices.", + add_completion=False, + no_args_is_help=False, +) + + +def version_callback(value: bool): + if value: + from importlib.metadata import version + + try: + typer.echo(f"djinit v{version('djinitx')}") + except Exception: + typer.echo("djinit (version unknown)") + raise typer.Exit() + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + version: Annotated[ + Optional[bool], + typer.Option("--version", "-v", callback=version_callback, is_eager=True, help="Show the version and exit."), + ] = None, +): + """ + djinit: Django Project Initializer + """ + if ctx.invoked_subcommand is None: + setup() + + +@app.command("secret") +def secret( + count: Annotated[int, typer.Option("--count", "-c", help="Number of keys to generate")] = 3, + length: Annotated[int, typer.Option("--length", "-l", help="Length of each secret key")] = 50, +): + """ + Generate Django secret keys. + """ + keys = generate_multiple_keys(count, length) + UIFormatter.print_info("") + display_secret_keys(keys) + + instructions = Text() + instructions.append("📋 Usage Instructions:\n", style=UIColors.ACCENT) + instructions.append("1. Copy the appropriate secret key for your environment\n") + instructions.append("2. Add it to your .env file:\n") + instructions.append(" SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE) + instructions.append("3. Or set it as an environment variable:\n") + instructions.append(" export SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE) + instructions.append("4. Never commit secret keys to version control!\n", style=UIColors.WARNING) + + console.print(Panel(instructions, title="💡 How to Use", border_style="blue")) + console.print() + + +@app.command("app") +def create_app(app_names: Annotated[list[str], typer.Argument(help="Name(s) of the Django app(s) to create")]): + """ + Create one or more Django apps. + """ + UIFormatter.print_info("") + UIFormatter.print_header("Django App Creation") + UIFormatter.print_info("") + + # Process app names (handle commma separation if passed as single arg) + processed_names = [] + for name in app_names: + parts = [p.strip() for p in name.split(",") if p.strip()] + processed_names.extend(parts) + + processed_names = list(dict.fromkeys(processed_names)) # Dedup + + if not processed_names: + UIFormatter.print_error("App name cannot be empty") + raise typer.Exit(1) + + for name in processed_names: + is_valid, error_msg = validate_app_name(name) + if not is_valid: + UIFormatter.print_error(f"Invalid app name '{name}': {error_msg}") + raise typer.Exit(1) + + any_failure = False + for name in processed_names: + creator = AppCreator(name) + success = creator.create_app() + if not success: + UIFormatter.print_error(f"Failed to create Django app '{name}'") + any_failure = True + break # Stop on failure? Or continue? Original code broke loop on failure. + + if any_failure: + raise typer.Exit(1) + + instructions = Text() + instructions.append("🚀 Next Steps:\n", style=UIColors.ACCENT) + instructions.append("1. The app(s) have been added to INSTALLED_APPS in settings/base.py\n") + instructions.append("2. Create your models in each app's models.py\n") + instructions.append("3. Run migrations: just makemigrations\n") + instructions.append("4. Apply migrations: just migrate\n") + instructions.append("5. Create views, serializers and routes(URLs) for your app(s)\n") + + console.print(Panel(instructions, title="💡 What's Next", border_style="green")) + UIFormatter.print_info("") + + +@app.command("setup") +def setup(): + """ + Interactively set up a new Django project. + """ + UIFormatter.print_header("Django Project Setup") + + project_dir, project_name, primary_app, app_names, metadata = get_user_input() + + orchestrator = SetupCreator(project_dir, project_name, primary_app, app_names, metadata) + success = orchestrator.create() + + if success: + UIFormatter.print_info("") + UIFormatter.print_header("Setup Complete!") + console.print(f"[bold green]Project '{project_name}' has been successfully created![/bold green]") + + instructions = Text() + instructions.append("🚀 Next Steps:\n", style=UIColors.ACCENT) + instructions.append(f"1. cd {project_dir}\n") + instructions.append("2. Install dependencies: pip install -r requirements.txt\n") + instructions.append("3. Run migrations: just migrate (or python manage.py migrate)\n") + instructions.append("4. Start server: just run (or python manage.py runserver)\n") + + console.print(Panel(instructions, title="Get Started", border_style="green")) + else: + UIFormatter.print_error("Project setup failed. Please check the logs above.") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/djinit/commands/__init__.py b/src/djinit/core/__init__.py similarity index 100% rename from djinit/commands/__init__.py rename to src/djinit/core/__init__.py diff --git a/src/djinit/core/base.py b/src/djinit/core/base.py new file mode 100644 index 0000000..452528a --- /dev/null +++ b/src/djinit/core/base.py @@ -0,0 +1,52 @@ +""" +Base classes for the djinit architecture. +Defines the common interface for services, commands, and utilities. +""" + +from abc import abstractmethod +from typing import Any + + +class BaseObject: + """ + Base object for all djinit classes. + """ + + def __init__(self, *args, **kwargs): + pass + + +class BaseService(BaseObject): + """ + Base class for all services. + Services handle business logic and orchestration. + """ + + def __init__(self, project_root: str = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.project_root = project_root + + +class BaseCommand(BaseObject): + """ + Base class for all CLI commands. + Commands handle user input and invoke services. + """ + + def __init__(self, args: Any, *args_list, **kwargs): + super().__init__(*args_list, **kwargs) + self.args = args + + @abstractmethod + def execute(self) -> None: + """Execute the command.""" + pass + + +class BaseUtils(BaseObject): + """ + Base class for utility collections. + Utilities provide helper functions. + """ + + pass diff --git a/djinit/core/config.py b/src/djinit/core/config.py similarity index 92% rename from djinit/core/config.py rename to src/djinit/core/config.py index 24e0e86..61b610e 100644 --- a/djinit/core/config.py +++ b/src/djinit/core/config.py @@ -5,7 +5,7 @@ Core configuration and constants for djinit. import sys # Django version used for project generation -DJANGO_VERSION = "5.2" +DJANGO_VERSION = "6.0.1" # Reserved names that cannot be used for projects or apps DJANGO_RESERVED = {"django", "test", "site-packages", "admin"} diff --git a/src/djinit/core/parser.py b/src/djinit/core/parser.py new file mode 100644 index 0000000..f672e23 --- /dev/null +++ b/src/djinit/core/parser.py @@ -0,0 +1,215 @@ +import re +from typing import Any, Dict, List + + +class InFileLogicParser: + """ + A lightweight template parser that uses comment-style markers for logic + and [[ variable ]] for substitutions. + """ + + def __init__(self, context: Dict[str, Any] = None): + self.context = context or {} + + def _get_value(self, key: str) -> str: + """ + Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]]. + For simplicity, it uses eval in the provided context for complex expressions, + but falls back to safe dictionary lookup for simple keys. + """ + key = key.strip() + try: + # Try evaluating the expression in the context + return str(eval(key, {"__builtins__": {}}, self.context)) + except (NameError, SyntaxError, KeyError, TypeError, AttributeError): + # Fallback for common patterns or just return the key as is if it fails + return f"[[ {key} ]]" + + def render(self, template_text: str, context: Dict[str, Any] = None) -> str: + if context is not None: + self.context = context + + final_lines = [] + # Stack stores boolean results of nested IF blocks + # Each element is (current_block_result, has_any_true_branch_executed) + stack: List[List[bool]] = [] + + lines = template_text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Handle @IF + if stripped.startswith("# @IF "): + expr = stripped[6:].strip() + try: + result = bool(eval(expr, {"__builtins__": {}}, self.context)) + except Exception: + result = False + stack.append([result, result]) + i += 1 + continue + + # Handle @ELSEIF + elif stripped.startswith("# @ELSEIF "): + if not stack: + final_lines.append(line) + i += 1 + continue + + expr = stripped[9:].strip() + current_stack = stack[-1] + + if current_stack[1]: + current_stack[0] = False + else: + try: + result = bool(eval(expr, {"__builtins__": {}}, self.context)) + except Exception: + result = False + current_stack[0] = result + if result: + current_stack[1] = True + i += 1 + continue + + # Handle @ELSE + elif stripped.startswith("# @ELSE"): + if not stack: + final_lines.append(line) + i += 1 + continue + + current_stack = stack[-1] + if current_stack[1]: + current_stack[0] = False + else: + current_stack[0] = True + current_stack[1] = True + i += 1 + continue + + # Handle @ENDIF + elif stripped.startswith("# @ENDIF"): + if stack: + stack.pop() + else: + final_lines.append(line) + i += 1 + continue + + # Handle @LOOP + elif stripped.startswith("# @LOOP "): + if stack and not all(s[0] for s in stack): + # Skip the entire loop block if inside a false conditional + loop_depth = 1 + i += 1 + while i < len(lines) and loop_depth > 0: + if lines[i].strip().startswith("# @LOOP "): + loop_depth += 1 + elif lines[i].strip().startswith("# @ENDLOOP"): + loop_depth -= 1 + i += 1 + continue + + parts = stripped[7:].strip().split(" in ") + if len(parts) == 2: + var_name = parts[0].strip() + iterable_name = parts[1].strip() + try: + iterable = eval(iterable_name, {"__builtins__": {}}, self.context) + except Exception: + iterable = [] + + # Capture loop body + loop_body = [] + i += 1 + loop_depth = 1 + while i < len(lines) and loop_depth > 0: + if lines[i].strip().startswith("# @LOOP "): + loop_depth += 1 + elif lines[i].strip().startswith("# @ENDLOOP"): + loop_depth -= 1 + + if loop_depth > 0: + loop_body.append(lines[i]) + i += 1 + + if i < len(lines): # Skip the @ENDLOOP line itself + i += 1 + + # Execute loop + if hasattr(iterable, "__iter__"): + old_val = self.context.get(var_name) + for val in iterable: + self.context[var_name] = val + rendered_body = self.render("\n".join(loop_body), self.context) + final_lines.extend(rendered_body.splitlines()) + if old_val is not None: + self.context[var_name] = old_val + else: + self.context.pop(var_name, None) + continue + else: + final_lines.append(line) + i += 1 + continue + + # Handle @ENDLOOP (should only be hit if out of sync or error) + elif stripped.startswith("# @ENDLOOP"): + i += 1 + continue + + # Check if we should skip this line based on conditional stack + if stack and not all(s[0] for s in stack): + i += 1 + continue + + # Variable substitution + rendered_line = line + matches = re.findall(r"\[\[\s*(.*?)\s*\]\]", rendered_line) + for match in matches: + value = self._get_value(match) + # Replace all variations of the variable syntax + rendered_line = re.sub(r"\[\[\s*" + re.escape(match) + r"\s*\]\]", value, rendered_line) + + # In-line @IF support + # Case 1: content # @IF cond + # Case 2: # @IF cond content + if "# @IF " in rendered_line: + parts = rendered_line.split("# @IF ", 1) + pre_content = parts[0].rstrip() + rest = parts[1].strip() + + # Split rest into expression and post-content (if any) + # This is tricky because the expression might have spaces. + # If there's an # @ENDIF on the same line, use it. + post_content = "" + expr = rest + if " # @ENDIF" in rest: + expr_parts = rest.split(" # @ENDIF", 1) + expr = expr_parts[0].strip() + post_content = expr_parts[1].lstrip() + elif "# @ENDIF" in rest: + expr_parts = rest.split("# @ENDIF", 1) + expr = expr_parts[0].strip() + post_content = expr_parts[1].lstrip() + + try: + if bool(eval(expr, {"__builtins__": {}}, self.context)): + # If Case 1, we want pre_content. If Case 2, we want post_content. + # Usually, if pre_content is empty (or just whitespace), it's Case 2. + if pre_content: + final_lines.append(pre_content + post_content) + else: + final_lines.append(post_content) + except Exception: + pass + i += 1 + continue + + final_lines.append(rendered_line) + i += 1 + + return "\n".join(final_lines) diff --git a/djinit/core/types.py b/src/djinit/core/types.py similarity index 100% rename from djinit/core/types.py rename to src/djinit/core/types.py diff --git a/djinit/core/__init__.py b/src/djinit/creators/__init__.py similarity index 100% rename from djinit/core/__init__.py rename to src/djinit/creators/__init__.py diff --git a/djinit/services/app.py b/src/djinit/creators/app.py similarity index 53% rename from djinit/services/app.py rename to src/djinit/creators/app.py index bcc5636..2281536 100644 --- a/djinit/services/app.py +++ b/src/djinit/creators/app.py @@ -6,29 +6,19 @@ Handles creation of Django apps and updating settings. import os from typing import Optional +from djinit.core.base import BaseService from djinit.ui.console import UIFormatter -from djinit.utils.common import ( - calculate_app_module_path, - create_directory_with_init, - create_file_from_template, - create_init_file, - detect_nested_structure_from_settings, - extract_existing_apps, - find_project_dir, - find_settings_path, - get_djinit_config, - insert_apps_into_user_defined_apps, - is_django_project, -) +from djinit.utils.common import CommonUtils from djinit.utils.django import DjangoHelper -class AppManager: +class AppCreator(BaseService): def __init__(self, app_name: str): + super().__init__() self.app_name = app_name self.current_dir = os.getcwd() self.manage_py_path = os.path.join(self.current_dir, "manage.py") - self.config = get_djinit_config(self.current_dir) + self.config = CommonUtils.get_djinit_config(self.current_dir) self._project_structure_cache = None def create_app(self) -> bool: @@ -38,12 +28,14 @@ class AppManager: ) return False - if self._is_restricted_structure(): - UIFormatter.print_error("The 'app' command is not supported for Unified or Single Folder structures.") - UIFormatter.print_info( - "Please add new apps manually by creating the necessary files and updating settings." + # Check structure type + structure_type = self._get_structure_type() + + if structure_type in ["unified", "single"]: + UIFormatter.print_warning( + f"Creating apps in {structure_type} structure. " + "Note: You'll need to manually organize your code within the existing structure." ) - return False if self._app_exists(): UIFormatter.print_error(f"Django app '{self.app_name}' already exists.") @@ -59,7 +51,49 @@ class AppManager: return True def _is_django_project(self) -> bool: - return is_django_project(self.current_dir) + return CommonUtils.is_django_project(self.current_dir) + + def _get_structure_type(self) -> str: + """Determine the project structure type.""" + if self.config: + structure = self.config.get("structure", {}) + if structure.get("unified"): + return "unified" + elif structure.get("single"): + return "single" + elif structure.get("predefined"): + return "predefined" + else: + return "standard" + + # Fallback: detect from filesystem + if self._is_predefined_structure(): + return "predefined" + if self._is_restricted_structure(): + # Try to distinguish between unified and single + if os.path.isdir(os.path.join(self.current_dir, "apps", "api")): + return "unified" + return "single" + return "standard" + + def _calculate_app_module_path(self, structure_type: str) -> str: + """Calculate the module path for the app based on structure type.""" + if structure_type == "unified": + # Unified structure: apps are not separate, return app name within apps module + # But actually in unified, we don't create separate apps - the structure is different + # For now, return a path that makes sense: apps.{appname} + return f"apps.{self.app_name}" + elif structure_type == "single": + # Single structure: app is within the single module + module_name = self.config.get("project_name") if self.config else self.app_name + return f"{module_name}.{self.app_name}" + elif structure_type == "predefined": + # Predefined: apps are in apps/ directory + return f"apps.{self.app_name}" + else: + # Standard: may be nested or not + is_nested, nested_dir, _ = self._get_project_structure() + return CommonUtils.calculate_app_module_path(self.app_name, is_nested, nested_dir) def _app_exists(self) -> bool: # For predefined structure, check in apps/ directory @@ -78,8 +112,17 @@ class AppManager: if self._is_predefined_structure(): return self._create_predefined_app(os.path.join(self.current_dir, "apps")) - _, _, apps_base_dir = self._get_project_structure() - success = DjangoHelper.startapp(self.app_name, apps_base_dir) + is_nested, nested_dir, apps_base_dir = self._get_project_structure() + + # Calculate app_module based on project structure + if is_nested and nested_dir: + # Normalize nested_dir (replace / with . for module path) + module_base = nested_dir.replace(os.path.sep, ".") + app_module = f"{module_base}.{self.app_name}" + else: + app_module = self.app_name + + success = DjangoHelper.startapp(self.app_name, apps_base_dir, app_module) if success: UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") else: @@ -87,8 +130,24 @@ class AppManager: return success def _add_to_installed_apps(self) -> bool: - settings_path = find_settings_path(self.current_dir) - if not settings_path: + structure_type = self._get_structure_type() + + # Find settings directory based on structure type + if structure_type == "unified": + settings_path = os.path.join(self.current_dir, "core", "settings") + elif structure_type == "single": + # For single structure, find the module name from config or detect it + module_name = self.config.get("project_name") if self.config else None + if not module_name: + # Fallback: find settings directory + settings_path = CommonUtils.find_settings_path(self.current_dir) + else: + settings_path = os.path.join(self.current_dir, module_name, "settings") + else: + # Standard or predefined structure + settings_path = CommonUtils.find_settings_path(self.current_dir) + + if not settings_path or not os.path.exists(settings_path): UIFormatter.print_error("Could not find Django settings directory") return False @@ -100,29 +159,50 @@ class AppManager: with open(base_settings_path) as f: content = f.read() - if self._is_predefined_structure(): - app_module_path = f"apps.{self.app_name}" - else: - is_nested, nested_dir, _ = self._get_project_structure() - app_module_path = calculate_app_module_path(self.app_name, is_nested, nested_dir) + # Calculate the app module path based on structure + app_module_path = self._calculate_app_module_path(structure_type) - existing_apps = extract_existing_apps(content) - if app_module_path in existing_apps: - UIFormatter.print_success(f"App '{app_module_path}' already configured in USER_DEFINED_APPS") + # Use full AppConfig path to properly support nested apps + # e.g. apps.users -> apps.users.apps.UsersConfig + app_config_name = self.app_name.title().replace("_", "") + full_app_config = f"{app_module_path}.apps.{app_config_name}Config" + + # We'll check if either the module path or the full config path exists + # but we'll prefer adding the full config path + + existing_apps = CommonUtils.extract_existing_apps(content) + + # Check if full config is already there + if full_app_config in existing_apps: + UIFormatter.print_success(f"App '{full_app_config}' already configured in USER_DEFINED_APPS") return True + # Check if legacy module path is there, if so, upgrade it + if app_module_path in existing_apps: + UIFormatter.print_info(f"Upgrading app configuration from '{app_module_path}' to '{full_app_config}'...") + updated_content = CommonUtils.replace_app_in_user_defined_apps(content, app_module_path, full_app_config) + + if updated_content: + with open(base_settings_path, "w") as f: + f.write(updated_content) + UIFormatter.print_success(f"Upgraded '{app_module_path}' to '{full_app_config}' in USER_DEFINED_APPS") + return True + else: + UIFormatter.print_error(f"Failed to upgrade app '{app_module_path}'") + return False + if "USER_DEFINED_APPS" not in content: UIFormatter.print_error("Could not find USER_DEFINED_APPS section in base.py") return False - updated_content = insert_apps_into_user_defined_apps(content, [app_module_path]) + updated_content = CommonUtils.insert_apps_into_user_defined_apps(content, [full_app_config]) if not updated_content: return False with open(base_settings_path, "w") as f: f.write(updated_content) - UIFormatter.print_success(f"Added '{app_module_path}' to USER_DEFINED_APPS in base.py") + UIFormatter.print_success(f"Added '{full_app_config}' to USER_DEFINED_APPS in base.py") return True def _get_project_structure(self) -> tuple[bool, Optional[str], str]: @@ -142,12 +222,12 @@ class AppManager: self._project_structure_cache = (nested, nested_dir, apps_base_dir) else: # Fallback to old detection method - project_dir, settings_base_path = find_project_dir(self.current_dir) + project_dir, settings_base_path = CommonUtils.find_project_dir(self.current_dir) if project_dir is None: self._project_structure_cache = (False, None, self.current_dir) else: - self._project_structure_cache = detect_nested_structure_from_settings( + self._project_structure_cache = CommonUtils.detect_nested_structure_from_settings( settings_base_path, self.current_dir ) return self._project_structure_cache @@ -175,7 +255,7 @@ class AppManager: return False # Check for Single Folder structure (models/ and api/ exist in project dir) - project_dir, _ = find_project_dir(self.current_dir) + project_dir, _ = CommonUtils.find_project_dir(self.current_dir) if project_dir: has_models = os.path.isdir(os.path.join(project_dir, "models")) has_api = os.path.isdir(os.path.join(project_dir, "api")) @@ -185,49 +265,18 @@ class AppManager: return False def _create_predefined_app(self, apps_dir: str) -> bool: - """Create an app following the predefined nested structure with custom templates.""" - app_dir = os.path.join(apps_dir, self.app_name) - os.makedirs(app_dir, exist_ok=True) - create_init_file(app_dir, f"Created apps/{self.app_name}/__init__.py") - - model_class_name = "".join([part.capitalize() for part in self.app_name.split("_")]) + """Create an app following the predefined structure with standard Django files.""" app_module = f"apps.{self.app_name}" - base_context = {"app_name": self.app_name, "app_module": app_module, "model_class_name": model_class_name} - create_file_from_template( - os.path.join(app_dir, "apps.py"), - "components/apps.j2", - {"app_name": self.app_name}, - f"Created apps/{self.app_name}/apps.py", - ) + # Use DjangoHelper.startapp to create standard Django app structure + success = DjangoHelper.startapp(self.app_name, apps_dir, app_module) - subfolders = { - "models": [(f"{self.app_name}.py", "presets/predefined/apps/generic/models.j2")], - "serializers": [(f"{self.app_name}_serializer.py", "presets/predefined/apps/generic/serializers.j2")], - "services": [(f"{self.app_name}_service.py", "presets/predefined/apps/generic/services.j2")], - "views": [(f"{self.app_name}_view.py", "presets/predefined/apps/generic/views.j2")], - "tests": [(f"test_{self.app_name}_api.py", "presets/predefined/apps/generic/tests.j2")], - } - for folder, files in subfolders.items(): - folder_path = os.path.join(app_dir, folder) - create_directory_with_init(folder_path, f"Created apps/{self.app_name}/{folder}/__init__.py") - for filename, template in files: - file_path = os.path.join(folder_path, filename) - create_file_from_template( - file_path, template, base_context, f"Created apps/{self.app_name}/{folder}/{filename}" - ) + if success: + UIFormatter.print_success(f"Created Django app '{self.app_name}' with predefined structure in {apps_dir}") + else: + UIFormatter.print_error(f"Failed to create Django app '{self.app_name}'") - create_file_from_template( - os.path.join(app_dir, "urls.py"), - "presets/predefined/apps/generic/urls.j2", - {"app_name": self.app_name, "app_module": app_module}, - f"Created apps/{self.app_name}/urls.py", - ) - - self._add_to_api_v1_urls(self.app_name) - - UIFormatter.print_success(f"Created Django app '{self.app_name}' with predefined structure in {apps_dir}") - return True + return success def _add_to_api_v1_urls(self, app_name: str) -> None: api_v1_urls = os.path.join(self.current_dir, "api", "v1", "urls.py") diff --git a/djinit/services/files.py b/src/djinit/creators/files.py similarity index 64% rename from djinit/services/files.py rename to src/djinit/creators/files.py index 34f3a3d..6b7ce39 100644 --- a/djinit/services/files.py +++ b/src/djinit/creators/files.py @@ -5,20 +5,14 @@ Handles creation and modification of project files. import os -from djinit.services.templates import template_engine -from djinit.utils.common import ( - calculate_app_module_paths, - change_cwd, - create_directory_with_init, - create_file_from_template, - create_file_with_content, - get_package_name, -) +from djinit.core.base import BaseService +from djinit.templater import template_engine +from djinit.utils.common import CommonUtils -class FileManager: +class FileCreator(BaseService): def __init__(self, project_root: str, project_name: str, app_names: list, metadata: dict): - self.project_root = project_root + super().__init__(project_root=project_root) self.project_name = project_name self.app_names = app_names self.metadata = metadata @@ -38,9 +32,9 @@ class FileManager: ) -> None: """Helper to render template and create file.""" target_dir = base_dir or self.project_root - with change_cwd(target_dir): + with CommonUtils.change_cwd(target_dir): content = template_engine.render_template(template_path, context) - create_file_with_content(filepath, content, message, should_format=should_format) + CommonUtils.create_file_with_content(filepath, content, message, should_format=should_format) def _create_files_from_specs(self, base_dir: str, folder_specs: dict, base_context: dict = None) -> None: """Helper to create multiple folders and files from a specification dict.""" @@ -48,25 +42,34 @@ class FileManager: for folder_name, file_templates in folder_specs.items(): folder_path = os.path.join(base_dir, folder_name) - create_directory_with_init(folder_path, f"Created {folder_path}/__init__.py") + CommonUtils.create_directory_with_init(folder_path, f"Created {folder_path}/__init__.py") for filename, template_path in file_templates: file_path = os.path.join(folder_path, filename) - create_file_from_template(file_path, template_path, base_context, f"Created {file_path}") + CommonUtils.create_file_from_template(file_path, template_path, base_context, f"Created {file_path}") - def _create_apps_py(self, app_dir: str, app_name: str, message: str = None, should_format: bool = False) -> None: + def _create_apps_py( + self, app_dir: str, app_name: str, app_module: str = None, message: str = None, should_format: bool = False + ) -> None: """Helper to create apps.py file for an app.""" apps_py_path = os.path.join(app_dir, "apps.py") - context = {"app_name": app_name} if app_name else {} + app_config_name = app_name.title().replace("_", "") + context = { + "app_name": app_name, + "app_config_name": app_config_name, + "app_module": app_module or app_name, + } msg = message or f"Created {os.path.basename(app_dir)}/apps.py" - create_file_from_template(apps_py_path, "components/apps.j2", context, msg, should_format=should_format) + CommonUtils.create_file_from_template( + apps_py_path, "components/apps.py-tpl", context, msg, should_format=should_format + ) def _create_subdirectories_with_init(self, base_dir: str, subdirs: list, prefix: str = "") -> None: """Helper to create multiple subdirectories with __init__.py files.""" for subdir in subdirs: subdir_path = os.path.join(base_dir, subdir) message = f"Created {prefix}{subdir}/__init__.py" if prefix else f"Created {subdir}/__init__.py" - create_directory_with_init(subdir_path, message) + CommonUtils.create_directory_with_init(subdir_path, message) def _create_settings_package(self, settings_dir: str, base_context: dict, prefix: str) -> None: """Helper to create settings package files (base, development, production).""" @@ -75,13 +78,27 @@ class FileManager: secret_key = generate_secret_key() dev_context = {"secret_key": secret_key} + # Prepare context for base.py with full AppConfig paths + base_settings_context = base_context.copy() + if "app_names" in base_settings_context: + transformed_apps = [] + for app in base_settings_context["app_names"]: + # Transform to full AppConfig path: module.path -> module.path.apps.ConfigName + short_name = app.split(".")[-1] + config_name = short_name.title().replace("_", "") + "Config" + # If it's the special "apps" container in unified structure, we might want to skip or handle differently + # But assumes all user apps follow standard djinit structure + full_path = f"{app}.apps.{config_name}" + transformed_apps.append(full_path) + base_settings_context["app_names"] = transformed_apps + for filename, context in [ - ("base.py", base_context), + ("base.py", base_settings_context), ("development.py", dev_context), - ("production.py", base_context), + ("production.py", base_settings_context), ]: filepath = os.path.join(settings_dir, filename) - template_path = f"config/settings/{filename.replace('.py', '')}.j2" + template_path = f"config/settings/{filename.replace('.py', '')}.py-tpl" self._render_and_create_file( filepath, template_path, @@ -98,14 +115,14 @@ class FileManager: ) -> None: """Helper to create urls.py, wsgi.py, and asgi.py.""" - urls_context = {"api_module": api_module} + urls_context = {"url_type": "project", "api_module": api_module, "include_api": True} if comment_out_api: urls_context["comment_out_api_url"] = True files = [ - ("urls.py", "config/urls/with_api.j2", urls_context), - ("wsgi.py", "config/wsgi.j2", {"project_name": prefix}), - ("asgi.py", "config/asgi.j2", {"project_name": prefix}), + ("urls.py", "config/urls.py-tpl", urls_context), + ("wsgi.py", "config/wsgi.py-tpl", {"project_name": prefix}), + ("asgi.py", "config/asgi.py-tpl", {"project_name": prefix}), ] for filename, template, context in files: @@ -115,7 +132,7 @@ class FileManager: ) def create_gitignore(self) -> None: - self._render_and_create_file(".gitignore", "project/gitignore.j2", {}, "Created .gitignore file") + self._render_and_create_file(".gitignore", "project/gitignore-tpl", {}, "Created .gitignore file") def create_requirements(self) -> None: context = { @@ -124,7 +141,7 @@ class FileManager: } self._render_and_create_file( "requirements.txt", - "project/requirements.j2", + "project/requirements-tpl", context, "Created requirements.txt with Django dependencies", ) @@ -137,7 +154,7 @@ class FileManager: "unified_structure": bool(self.metadata.get("unified_structure")), "module_name": self.module_name, } - self._render_and_create_file("README.md", "project/readme.j2", context, "Created README.md file") + self._render_and_create_file("README.md", "project/readme.md-tpl", context, "Created README.md file") def create_env_file(self) -> None: """Create .env.sample file with environment variables.""" @@ -148,32 +165,39 @@ class FileManager: } self._render_and_create_file( ".env.sample", - "project/env_sample.j2", + "project/env_sample-tpl", context, "Created .env.sample file with generated secret key", ) - def create_pyproject_toml(self, metadata: dict) -> None: + def create_pyproject(self, metadata: dict) -> None: package_name = metadata.get("package_name", "backend") - package_name = get_package_name(package_name) - context = {"package_name": package_name, "project_name": self.project_name} + package_name = CommonUtils.get_package_name(package_name) + context = { + "package_name": package_name, + "project_name": self.project_name, + "database_type": metadata.get("database_type", "postgresql"), + } self._render_and_create_file( "pyproject.toml", - "project/pyproject_toml.j2", + "project/pyproject.toml-tpl", context, "Created pyproject.toml file for uv", ) def create_project_urls(self) -> None: - """Create project urls.py using project_urls.j2 template (replaces update_project_urls).""" - effective_app_modules = calculate_app_module_paths(self.app_names, self.metadata) + """Create project urls.py using project_config/urls.py-tpl template (replaces update_project_urls).""" + effective_app_modules = CommonUtils.calculate_app_module_paths(self.app_names, self.metadata) context = { + "url_type": "project", "project_name": self.project_name, "app_names": effective_app_modules, + "include_apps": True, + "include_api": True, } self._render_and_create_file( "urls.py", - "config/urls/with_apps.j2", + "config/urls.py-tpl", context, "Created project urls.py with comprehensive URL configuration", base_dir=self.project_configs, @@ -184,66 +208,50 @@ class FileManager: context = {"project_name": self.project_name} self._render_and_create_file( "justfile", - "project/justfile.j2", + "project/justfile-tpl", context, "Created justfile with Django development tasks", ) def create_predefined_structure(self) -> None: + """Create predefined structure with folder organization only (no example code).""" apps_dir = os.path.join(self.project_root, "apps") - create_directory_with_init(apps_dir, "Created apps/__init__.py") - - users_dir = os.path.join(apps_dir, "users") - create_directory_with_init(users_dir, "Created apps/users/__init__.py") - self._create_apps_py(users_dir, "users") - - users_subfolders = { - "models": [("user.py", "presets/predefined/apps/users/models.j2")], - "serializers": [("user_serializer.py", "presets/predefined/apps/users/serializers.j2")], - "services": [("user_service.py", "presets/predefined/apps/users/services.j2")], - "views": [("user_view.py", "presets/predefined/apps/users/views.j2")], - "tests": [("test_user_api.py", "presets/predefined/apps/users/tests.j2")], - } - self._create_files_from_specs(users_dir, users_subfolders, {"app_module": "apps.users"}) - - users_urls_path = os.path.join(users_dir, "urls.py") - create_file_from_template( - users_urls_path, - "presets/predefined/apps/users/urls.j2", - {"app_module": "apps.users"}, - "Created apps/users/urls.py", - ) + CommonUtils.create_directory_with_init(apps_dir, "Created apps/__init__.py") + # Create core app with empty subfolders (no example utilities/middleware) core_dir = os.path.join(apps_dir, "core") - create_directory_with_init(core_dir, "Created apps/core/__init__.py") + CommonUtils.create_directory_with_init(core_dir, "Created apps/core/__init__.py") - core_subfolders = { - "utils": [("responses.py", "presets/predefined/core/utils/responses.j2")], - "mixins": [("timestamped_model.py", "presets/predefined/core/mixins/timestamped_model.j2")], - "middleware": [("request_logger.py", "presets/predefined/core/middleware/request_logger.j2")], - } - self._create_files_from_specs(core_dir, core_subfolders, {}) - - exceptions_path = os.path.join(core_dir, "exceptions.py") - create_file_from_template( - exceptions_path, "presets/predefined/core/exceptions.j2", {}, "Created apps/core/exceptions.py" - ) + # Create empty folders for utilities, mixins, middleware (developers add their own) + core_subfolders = ["utils", "mixins", "middleware"] + self._create_subdirectories_with_init(core_dir, core_subfolders, "apps/core/") + # Create api directory structure api_dir = os.path.join(self.project_root, "api") - create_directory_with_init(api_dir, "Created api/__init__.py") + CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py") api_urls_path = os.path.join(api_dir, "urls.py") - create_file_from_template(api_urls_path, "presets/predefined/api/urls.j2", {}, "Created api/urls.py") + CommonUtils.create_file_from_template( + api_urls_path, + "config/urls.py-tpl", + {"url_type": "api_root", "app_name": "api", "version": "v1", "api_module": "api"}, + "Created api/urls.py", + ) api_v1_dir = os.path.join(api_dir, "v1") - create_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") + CommonUtils.create_directory_with_init(api_v1_dir, "Created api/v1/__init__.py") api_v1_urls_path = os.path.join(api_v1_dir, "urls.py") - create_file_from_template(api_v1_urls_path, "presets/predefined/api/v1/urls.j2", {}, "Created api/v1/urls.py") + CommonUtils.create_file_from_template( + api_v1_urls_path, + "config/urls.py-tpl", + {"url_type": "api_version", "app_name": "v1", "app_list": [], "app_module": "apps"}, + "Created api/v1/urls.py", + ) project_urls_path = os.path.join(self.project_configs, "urls.py") - create_file_from_template( + CommonUtils.create_file_from_template( project_urls_path, - "config/urls/with_api.j2", - {"api_module": "api"}, + "config/urls.py-tpl", + {"url_type": "project", "api_module": "api", "include_api": True}, "Updated project urls.py to include API routes", should_format=True, ) @@ -251,10 +259,10 @@ class FileManager: def create_unified_structure(self) -> None: # 1. Create 'core' directory (Project Config) core_dir = os.path.join(self.project_root, "core") - create_directory_with_init(core_dir, "Created core/__init__.py") + CommonUtils.create_directory_with_init(core_dir, "Created core/__init__.py") settings_dir = os.path.join(core_dir, "settings") - create_directory_with_init(settings_dir, "Created core/settings/__init__.py") + CommonUtils.create_directory_with_init(settings_dir, "Created core/settings/__init__.py") # Create settings files base_context = { @@ -270,10 +278,10 @@ class FileManager: # 2. Create 'apps' directory (The Main App) apps_dir = os.path.join(self.project_root, "apps") - create_directory_with_init(apps_dir, "Created apps/__init__.py") + CommonUtils.create_directory_with_init(apps_dir, "Created apps/__init__.py") # Create apps.py for the 'apps' app - self._create_apps_py(apps_dir, "apps", "Created apps/apps.py", should_format=True) + self._create_apps_py(apps_dir, "apps", app_module="apps", message="Created apps/apps.py", should_format=True) # Create sub-components as directories with __init__.py components = ["admin", "models", "serializers", "tests", "urls", "views"] @@ -281,33 +289,34 @@ class FileManager: # Create 'api' directory with v1 api_dir = os.path.join(apps_dir, "api") - create_directory_with_init(api_dir, "Created apps/api/__init__.py") + CommonUtils.create_directory_with_init(api_dir, "Created apps/api/__init__.py") - create_file_from_template( + CommonUtils.create_file_from_template( os.path.join(api_dir, "urls.py"), - "presets/predefined/api/urls.j2", - {}, + "config/urls.py-tpl", + {"url_type": "api_root", "app_name": "api", "version": "v1", "api_module": "apps.api"}, "Created apps/api/urls.py", should_format=True, ) api_v1_dir = os.path.join(api_dir, "v1") - create_directory_with_init(api_v1_dir, "Created apps/api/v1/__init__.py") + CommonUtils.create_directory_with_init(api_v1_dir, "Created apps/api/v1/__init__.py") - create_file_from_template( + CommonUtils.create_file_from_template( os.path.join(api_v1_dir, "urls.py"), - "presets/predefined/api/v1/urls.j2", - {}, + "config/urls.py-tpl", + {"url_type": "api_version", "app_name": "v1", "app_list": [], "app_module": "apps"}, "Created apps/api/v1/urls.py", should_format=True, ) def create_single_structure(self) -> None: + """Create single folder structure with minimal files (no example code).""" project_dir = os.path.join(self.project_root, self.module_name) - create_directory_with_init(project_dir, f"Created {self.module_name}/__init__.py") + CommonUtils.create_directory_with_init(project_dir, f"Created {self.module_name}/__init__.py") settings_dir = os.path.join(project_dir, "settings") - create_directory_with_init(settings_dir, f"Created {self.module_name}/settings/__init__.py") + CommonUtils.create_directory_with_init(settings_dir, f"Created {self.module_name}/settings/__init__.py") # Create settings files base_context = { @@ -323,33 +332,21 @@ class FileManager: project_dir, self.module_name, api_module=f"{self.module_name}.api", comment_out_api=True ) + # Create component folders with __init__.py only (no example code) components = ["admin", "api", "models", "tests"] self._create_subdirectories_with_init(project_dir, components, f"{self.module_name}/") - create_file_from_template( - os.path.join(project_dir, "admin", "__init__.py"), - "components/admin.j2", - {}, - f"Created {self.module_name}/admin/__init__.py", - ) - - create_file_from_template( - os.path.join(project_dir, "models", "__init__.py"), - "components/models.j2", - {}, - f"Created {self.module_name}/models/__init__.py", - ) - - create_file_from_template( + # Create README files for guidance + CommonUtils.create_file_from_template( os.path.join(project_dir, "api", "README.md"), - "components/api_readme.j2", + "components/api_readme.md-tpl", {"module_name": self.module_name}, f"Created {self.module_name}/api/README.md", ) - create_file_from_template( + CommonUtils.create_file_from_template( os.path.join(project_dir, "models", "README.md"), - "components/models_readme.j2", + "components/models_readme.md-tpl", {"module_name": self.module_name}, f"Created {self.module_name}/models/README.md", ) @@ -358,7 +355,7 @@ class FileManager: context = {"project_name": self.project_name} self._render_and_create_file( "Procfile", - "project/procfile.j2", + "project/procfile-tpl", context, "Created Procfile with Gunicorn configuration", ) @@ -367,7 +364,7 @@ class FileManager: context = {"python_version": "3.13"} self._render_and_create_file( "runtime.txt", - "project/runtime_txt.j2", + "project/runtime_txt-tpl", context, "Created runtime.txt with Python version specification", ) @@ -379,7 +376,7 @@ class FileManager: workflow_file = os.path.join(github_dir, "ci.yml") self._render_and_create_file( workflow_file, - "project/ci/github_actions.j2", + "project/ci/github_actions-tpl", context, "Created Github Actions workflow (ci.yml)", base_dir=self.project_root, @@ -389,7 +386,7 @@ class FileManager: context = {"project_name": self.project_name, "module_name": self.module_name} self._render_and_create_file( ".gitlab-ci.yml", - "project/ci/gitlab_ci.j2", + "project/ci/gitlab_ci-tpl", context, "Created GitLab CI configuration (.gitlab-ci.yml)", ) @@ -426,4 +423,6 @@ class FileManager: } filepath = os.path.join(self.project_root, ".djinit") - create_file_with_content(filepath, json.dumps(config, indent=4), "Created .djinit configuration file") + CommonUtils.create_file_with_content( + filepath, json.dumps(config, indent=4), "Created .djinit configuration file" + ) diff --git a/djinit/services/project.py b/src/djinit/creators/project.py similarity index 61% rename from djinit/services/project.py rename to src/djinit/creators/project.py index 9cc0f98..57ac51b 100644 --- a/djinit/services/project.py +++ b/src/djinit/creators/project.py @@ -5,32 +5,29 @@ Handles creation of Django projects and apps. import os +from djinit.core.base import BaseService from djinit.ui.console import UIFormatter -from djinit.utils.common import ( - calculate_app_module_paths, - create_directory_with_init, - extract_existing_apps, - get_base_settings_path, - insert_apps_into_user_defined_apps, - read_base_settings, -) +from djinit.utils.common import CommonUtils from djinit.utils.django import DjangoHelper -class ProjectManager: +class ProjectCreator(BaseService): def __init__(self, project_dir: str, project_name: str, app_names: list, metadata: dict): + super().__init__(project_root=os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir)) self.project_dir = project_dir self.project_name = project_name self.app_names = app_names self.metadata = metadata - self.project_root = os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir) + # self.project_root is set by BaseService via super().__init__ but we need to ensure it matches logic + # logic was: self.project_root = os.getcwd() if project_dir == "." else os.path.join(os.getcwd(), project_dir) + # which I passed to super. So I can remove the line setting self.project_root here. self.module_name = metadata.get("project_module_name") or self.project_name def create_project(self) -> None: os.makedirs(self.project_root, exist_ok=True) unified = self.metadata.get("unified_structure", False) - DjangoHelper.startproject(self.module_name, self.project_root, unified=unified) + DjangoHelper.startproject(self.module_name, self.project_root, unified=unified, metadata=self.metadata) UIFormatter.print_success(f"Django project '{self.project_name}' created successfully!") def _get_apps_base_dir(self) -> str: @@ -44,39 +41,79 @@ class ProjectManager: apps_base_dir = self._get_apps_base_dir() if apps_base_dir != self.project_root: - create_directory_with_init(apps_base_dir, f"Created {os.path.basename(apps_base_dir)}/__init__.py") + CommonUtils.create_directory_with_init( + apps_base_dir, f"Created {os.path.basename(apps_base_dir)}/__init__.py" + ) - for app_name in self.app_names: - DjangoHelper.startapp(app_name, apps_base_dir) + # Calculate module paths for correct AppConfig generation (e.g. src.users) + app_module_paths = CommonUtils.calculate_app_module_paths(self.app_names, self.metadata) + + for i, app_name in enumerate(self.app_names): + app_module = app_module_paths[i] + DjangoHelper.startapp(app_name, apps_base_dir, app_module) UIFormatter.print_success(f"Django app '{app_name}' created successfully!") self.add_apps_to_settings() def add_apps_to_settings(self) -> None: """Add all apps to USER_DEFINED_APPS in base.py settings file.""" - base_settings_path = get_base_settings_path(self.project_root, self.module_name) + base_settings_path = CommonUtils.get_base_settings_path(self.project_root, self.module_name) if not os.path.exists(base_settings_path): from djinit.utils.exceptions import ConfigError raise ConfigError("Could not find base.py settings file") - content = read_base_settings(self.project_root, self.module_name) + content = CommonUtils.read_base_settings(self.project_root, self.module_name) if content is None: from djinit.utils.exceptions import ConfigError raise ConfigError("Could not read base.py settings file") - app_module_paths = calculate_app_module_paths(self.app_names, self.metadata) + app_module_paths = CommonUtils.calculate_app_module_paths(self.app_names, self.metadata) - existing_apps = extract_existing_apps(content) - apps_to_add = [app for app in app_module_paths if app not in existing_apps] + # Transform to full AppConfig paths (e.g. apps.users.apps.UsersConfig) + full_config_paths = [] + for app in app_module_paths: + short_name = app.split(".")[-1] + config_name = short_name.title().replace("_", "") + "Config" + full_config_paths.append(f"{app}.apps.{config_name}") + existing_apps = CommonUtils.extract_existing_apps(content) + + # Check against both module path and full config path to avoid duplicates + apps_to_add = [] + for i, full_path in enumerate(full_config_paths): + mod_path = app_module_paths[i] + if full_path not in existing_apps and mod_path not in existing_apps: + apps_to_add.append(full_path) + elif mod_path in existing_apps: + # If legacy module path exists, we could upgrade it here, + # but ProjectManager is typically initialized on creation. + # For now, let's just not add a duplicate. + # Actually, let's upgrade it if we can + pass + + # If we found apps to add, insert them if not apps_to_add: + # Check if we need to upgrade any legacy paths + upgraded = False + for i, full_path in enumerate(full_config_paths): + mod_path = app_module_paths[i] + if mod_path in existing_apps and full_path not in existing_apps: + content = CommonUtils.replace_app_in_user_defined_apps(content, mod_path, full_path) + upgraded = True + + if upgraded: + with open(base_settings_path, "w") as f: + f.write(content) + UIFormatter.print_success("Upgraded existing apps to full AppConfig paths") + return + UIFormatter.print_success("All apps already configured in USER_DEFINED_APPS") return - updated_content = insert_apps_into_user_defined_apps(content, apps_to_add) + updated_content = CommonUtils.insert_apps_into_user_defined_apps(content, apps_to_add) if not updated_content: from djinit.utils.exceptions import ConfigError @@ -89,8 +126,10 @@ class ProjectManager: UIFormatter.print_success(f"Added apps to USER_DEFINED_APPS: {added_apps_str}") def validate_project_structure(self) -> None: + """Validate project structure - only checks essential configuration files.""" join = lambda *args: os.path.join(self.project_root, *args) + # Core Django project files required_files = [ join("manage.py"), join(self.module_name, "__init__.py"), @@ -125,18 +164,19 @@ class ProjectManager: required_files.extend(unified_files) elif not self.metadata.get("predefined_structure") and not self.metadata.get("single_structure"): + # Standard structure - validate all standard Django app files for app_name in self.app_names: app_path = lambda f, app=app_name: os.path.join(apps_base_dir, app, f) app_files = [ app_path("__init__.py"), app_path("apps.py"), + app_path("admin.py"), app_path("models.py"), app_path("views.py"), app_path("serializers.py"), - app_path("routes.py"), + app_path("urls.py"), app_path("tests.py"), app_path("migrations"), - app_path("admin.py"), ] required_files.extend(app_files) diff --git a/djinit/cli.py b/src/djinit/creators/setup.py similarity index 64% rename from djinit/cli.py rename to src/djinit/creators/setup.py index 0cc0641..ac44f53 100644 --- a/djinit/cli.py +++ b/src/djinit/creators/setup.py @@ -1,17 +1,17 @@ -""" -Main CLI orchestrator for djinit. -Coordinates between different managers to create a complete Django project. -""" - import os from typing import Callable, List, Tuple -from djinit.services.files import FileManager -from djinit.services.project import ProjectManager +from djinit.creators.files import FileCreator +from djinit.creators.project import ProjectCreator from djinit.ui.console import UIFormatter -class Cli: +class SetupCreator: + """ + Orchestrator for creating a complete Django project. + Combines ProjectCreator and FileCreator to set up the environment. + """ + def __init__(self, project_dir: str, project_name: str, primary_app: str, app_names: list, metadata: dict): self.project_dir = project_dir self.project_name = project_name @@ -23,8 +23,8 @@ class Cli: else: self.project_root = os.path.join(os.getcwd(), project_dir) - self.project_manager = ProjectManager(project_dir, project_name, app_names, metadata) - self.file_manager = FileManager(self.project_root, project_name, app_names, metadata) + self.project_creator = ProjectCreator(project_dir, project_name, app_names, metadata) + self.file_creator = FileCreator(self.project_root, project_name, app_names, metadata) def _normalize_metadata(self) -> None: """Normalize metadata and app names based on structure type.""" @@ -34,7 +34,7 @@ class Cli: self.metadata.setdefault("nested_dir", "apps") if not self.app_names: self.app_names = ["users", "core"] - self.project_manager.app_names = self.app_names + self.project_creator.app_names = self.app_names elif self.metadata.get("unified_structure"): self.metadata.setdefault("project_module_name", "core") @@ -42,44 +42,44 @@ class Cli: self.metadata.setdefault("nested_dir", "apps") if not self.app_names: self.app_names = [] - self.project_manager.app_names = self.app_names + self.project_creator.app_names = self.app_names elif self.metadata.get("single_structure"): self.metadata.setdefault("nested_apps", False) if not self.app_names: self.app_names = [] - self.project_manager.app_names = self.app_names + self.project_creator.app_names = self.app_names - def run_setup(self) -> bool: + def create(self) -> bool: + """Run the full setup process.""" self._normalize_metadata() steps: List[Tuple[str, Callable[[], None]]] = [] - steps.append(("Creating Django project", self.project_manager.create_project)) + steps.append(("Creating Django project", self.project_creator.create_project)) if self.metadata.get("unified_structure"): - steps.append(("Creating unified structure", self.file_manager.create_unified_structure)) + steps.append(("Creating unified structure", self.file_creator.create_unified_structure)) elif self.metadata.get("single_structure"): - steps.append(("Creating single folder structure", self.file_manager.create_single_structure)) + steps.append(("Creating single folder structure", self.file_creator.create_single_structure)) elif self.metadata.get("predefined_structure"): - steps.append(("Creating predefined structure", self.file_manager.create_predefined_structure)) - steps.append(("Adding apps to settings", self.project_manager.add_apps_to_settings)) + steps.append(("Creating predefined structure", self.file_creator.create_predefined_structure)) + steps.append(("Adding apps to settings", self.project_creator.add_apps_to_settings)) else: - steps.append(("Creating Django apps", self.project_manager.create_apps)) - steps.append(("Creating project URLs", self.file_manager.create_project_urls)) + steps.append(("Creating Django apps", self.project_creator.create_apps)) + steps.append(("Creating project URLs", self.file_creator.create_project_urls)) steps.extend( [ - ("Validating project structure", self.project_manager.validate_project_structure), + ("Validating project structure", self.project_creator.validate_project_structure), ("Creating utility files", self._create_utility_files), - ("Creating Procfile", self.file_manager.create_procfile), - ("Creating Justfile", self.file_manager.create_justfile), - ("Creating runtime.txt", self.file_manager.create_runtime_txt), + ("Creating Procfile", self.file_creator.create_procfile), + ("Creating Justfile", self.file_creator.create_justfile), + ("Creating runtime.txt", self.file_creator.create_runtime_txt), ("Creating CI/CD pipelines", self._create_cicd_pipelines), ] ) - total_steps = len(steps) total_steps = len(steps) success = True @@ -99,12 +99,12 @@ class Cli: def _create_utility_files(self) -> None: utility_steps = [ - self.file_manager.create_gitignore, - self.file_manager.create_requirements, - self.file_manager.create_readme, - self.file_manager.create_env_file, - self.file_manager.create_djinit_config, - lambda: self.file_manager.create_pyproject_toml(self.metadata), + self.file_creator.create_gitignore, + self.file_creator.create_requirements, + self.file_creator.create_readme, + self.file_creator.create_env_file, + self.file_creator.create_djinit_config, + lambda: self.file_creator.create_pyproject(self.metadata), ] for step_func in utility_steps: @@ -114,7 +114,7 @@ class Cli: def _create_cicd_pipelines(self) -> None: if self.metadata.get("use_github_actions", True): - self.file_manager.create_github_actions() + self.file_creator.create_github_actions() if self.metadata.get("use_gitlab_ci", True): - self.file_manager.create_gitlab_ci() + self.file_creator.create_gitlab_ci() diff --git a/djinit/services/templates.py b/src/djinit/templater/__init__.py similarity index 65% rename from djinit/services/templates.py rename to src/djinit/templater/__init__.py index 4f74608..6df7d13 100644 --- a/djinit/services/templates.py +++ b/src/djinit/templater/__init__.py @@ -7,10 +7,11 @@ import os from pathlib import Path from typing import Any, Dict -from jinja2 import Environment, FileSystemLoader, Template +from djinit.core.base import BaseService +from djinit.core.parser import InFileLogicParser -class TemplateEngine: +class TemplateEngine(BaseService): """Template engine for rendering file templates.""" def __init__(self, template_dir: str = None): @@ -20,29 +21,35 @@ class TemplateEngine: template_dir: Path to templates directory. If None, uses default. """ if template_dir is None: - # djinit/services/templates.py -> djinit/services -> djinit -> templates + # djinit/templater/__init__.py -> djinit/templater -> djinit -> templates template_dir = Path(__file__).resolve().parent.parent / "templates" + super().__init__() + if not template_dir.exists(): raise FileNotFoundError(f"Template directory not found: {template_dir}") self.template_dir = str(template_dir) - self.env = Environment( - loader=FileSystemLoader(template_dir), trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True - ) + self.parser = InFileLogicParser() def render_template(self, template_name: str, context: Dict[str, Any]) -> str: """Render a template with the given context. Args: - template_name: Name of the template file (e.g., 'gitignore.j2') + template_name: Name of the template file (e.g., 'gitignore.py-tpl') context: Dictionary of variables to pass to the template Returns: Rendered template content as string """ - template = self.env.get_template(template_name) - return template.render(**context) + template_path = os.path.join(self.template_dir, template_name) + if not os.path.exists(template_path): + raise FileNotFoundError(f"Template not found: {template_path}") + + with open(template_path) as f: + template_text = f.read() + + return self.parser.render(template_text, context) def render_string(self, template_string: str, context: Dict[str, Any]) -> str: """Render a template string with the given context. @@ -54,24 +61,26 @@ class TemplateEngine: Returns: Rendered template content as string """ - template = Template(template_string, trim_blocks=True, lstrip_blocks=True) - return template.render(**context) + return self.parser.render(template_string, context) def get_template_names(self) -> list[str]: """Get list of available template files.""" if not os.path.exists(self.template_dir): return [] - return sorted([file for file in os.listdir(self.template_dir) if file.endswith(".j2")]) + templates = [] + for root, _, files in os.walk(self.template_dir): + for file in files: + if file.endswith("-tpl"): + rel_path = os.path.relpath(os.path.join(root, file), self.template_dir) + templates.append(rel_path) + return sorted(templates) def template_exists(self, template_name: str) -> bool: """Check if a template file exists. Args: template_name: Name of the template file - - Returns: - True if template exists, False otherwise """ template_path = os.path.join(self.template_dir, template_name) return os.path.exists(template_path) diff --git a/src/djinit/templater/base.py b/src/djinit/templater/base.py new file mode 100644 index 0000000..c5c28d3 --- /dev/null +++ b/src/djinit/templater/base.py @@ -0,0 +1,75 @@ +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from djinit.templater import template_engine + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TemplateFile: + """A template file to be rendered. + + The template file extension should end with '-tpl'. + """ + + path: Path + context: dict[str, Any] = field(default_factory=dict) + template_name: str | None = None # Optional override, otherwise derived from path name + -tpl + + +def create_file( + template_file: TemplateFile, + template_name: str | None = None, +) -> None: + """Create file based on a template. + + Args: + template_file: The TemplateFile object containing path and context. + template_name: Optional explicit template name. If not provided, + it looks for template_file.template_name, + or defaults to template_file.path.name + "-tpl". + """ + path = template_file.path + + logger.debug(f"Create file, {path}, if it doesn't exist") + + # Ensure parent directory exists + if not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + # Create __init__.py in parents if needed? + # CommonUtils.create_directory_with_init handles this usually, + # but here we are being more granular. + # For now, let's assume specific directories are created or we blindly create parents. + # But wait, django-new logic creates parents. + + if path.exists(): + # logic to overwrite or skip? django-new skips if exists usually, + # or maybe we want to force? + # For now, let's follow django-new: + logger.debug(f"Do not create template file, {path}, because it already exists") + # However, djinit usually overwrites or at least proceeds. + # Let's keep it safe. + pass + # Actually, let's overwrite if needed or warn. + # But CommonUtils.create_file_from_template overwrites. + # Let's stick to djinit behavior of overwriting/creating content. + + # Check explicitly passed template_name -> template_file.template_name -> path.name + "-tpl" + tpl_name = template_name or template_file.template_name or (path.name + "-tpl") + + try: + content = template_engine.render_template(tpl_name, template_file.context) + + with open(path, "w") as f: + f.write(content) + + logger.debug(f"Created template file, {path}") + # We might want to print success via UIFormatter, but maybe keep this low level. + + except FileNotFoundError: + # Fallback or error + logger.error(f"Template {tpl_name} not found.") + raise diff --git a/djinit/templates/components/admin.j2 b/src/djinit/templates/components/admin.py-tpl similarity index 100% rename from djinit/templates/components/admin.j2 rename to src/djinit/templates/components/admin.py-tpl diff --git a/djinit/templates/components/api_readme.j2 b/src/djinit/templates/components/api_readme.md-tpl similarity index 92% rename from djinit/templates/components/api_readme.j2 rename to src/djinit/templates/components/api_readme.md-tpl index 112cbe5..5d416ff 100644 --- a/djinit/templates/components/api_readme.j2 +++ b/src/djinit/templates/components/api_readme.md-tpl @@ -24,7 +24,7 @@ If you have a `User` model in `models/user.py`, create: ### 1. `api/user/serializers.py` ```python from rest_framework import serializers -from {{ module_name }}.models.user import User +from [[ module_name ]].models.user import User class UserSerializer(serializers.ModelSerializer): class Meta: @@ -35,7 +35,7 @@ class UserSerializer(serializers.ModelSerializer): ### 2. `api/user/views.py` ```python from rest_framework import viewsets -from {{ module_name }}.models.user import User +from [[ module_name ]].models.user import User from .serializers import UserSerializer class UserViewSet(viewsets.ModelViewSet): @@ -62,7 +62,7 @@ urlpatterns = [ from django.urls import path, include urlpatterns = [ - path('user/', include('{{ module_name }}.api.user.urls')), + path('user/', include('[[ module_name ]].api.user.urls')), # Add more API endpoints here ] ``` diff --git a/src/djinit/templates/components/apps.py-tpl b/src/djinit/templates/components/apps.py-tpl new file mode 100644 index 0000000..fdac009 --- /dev/null +++ b/src/djinit/templates/components/apps.py-tpl @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + + +class [[ app_config_name ]]Config(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' +# @IF app_module + name = '[[ app_module ]]' +# @ENDIF +# @IF not app_module + name = '[[ app_name ]]' +# @ENDIF diff --git a/djinit/templates/components/models.j2 b/src/djinit/templates/components/models.py-tpl similarity index 100% rename from djinit/templates/components/models.j2 rename to src/djinit/templates/components/models.py-tpl diff --git a/djinit/templates/components/models_readme.j2 b/src/djinit/templates/components/models_readme.md-tpl similarity index 98% rename from djinit/templates/components/models_readme.j2 rename to src/djinit/templates/components/models_readme.md-tpl index 867eebb..0bf247c 100644 --- a/djinit/templates/components/models_readme.j2 +++ b/src/djinit/templates/components/models_readme.md-tpl @@ -69,7 +69,7 @@ Create or update `admin/__init__.py`: ```python from django.contrib import admin -from {{ module_name }}.models import User +from [[ module_name ]].models import User @admin.register(User) class UserAdmin(admin.ModelAdmin): diff --git a/src/djinit/templates/components/serializers.py-tpl b/src/djinit/templates/components/serializers.py-tpl new file mode 100644 index 0000000..600d85c --- /dev/null +++ b/src/djinit/templates/components/serializers.py-tpl @@ -0,0 +1,5 @@ +"""generated with djinit""" + +from rest_framework import serializers + +# Create your serializers here. diff --git a/djinit/templates/components/tests.j2 b/src/djinit/templates/components/tests.py-tpl similarity index 100% rename from djinit/templates/components/tests.j2 rename to src/djinit/templates/components/tests.py-tpl diff --git a/djinit/templates/components/urls.j2 b/src/djinit/templates/components/urls.py-tpl similarity index 54% rename from djinit/templates/components/urls.j2 rename to src/djinit/templates/components/urls.py-tpl index 501e65a..4d77864 100644 --- a/djinit/templates/components/urls.j2 +++ b/src/djinit/templates/components/urls.py-tpl @@ -1,11 +1,7 @@ """generated with djinit""" from django.urls import path -from . import views - -app_name = '{{ app_name }}' urlpatterns = [ # Add your URL patterns here - # path('', views.index, name='index'), ] diff --git a/djinit/templates/components/views.j2 b/src/djinit/templates/components/views.py-tpl similarity index 60% rename from djinit/templates/components/views.j2 rename to src/djinit/templates/components/views.py-tpl index 7df6a0d..e67469e 100644 --- a/djinit/templates/components/views.j2 +++ b/src/djinit/templates/components/views.py-tpl @@ -1,5 +1,5 @@ """generated with djinit""" -from django.shortcuts import render +from rest_framework import viewsets # Create your views here. diff --git a/djinit/templates/config/asgi.j2 b/src/djinit/templates/config/asgi.py-tpl similarity index 79% rename from djinit/templates/config/asgi.j2 rename to src/djinit/templates/config/asgi.py-tpl index 06cf14c..0c5c879 100644 --- a/djinit/templates/config/asgi.j2 +++ b/src/djinit/templates/config/asgi.py-tpl @@ -7,5 +7,5 @@ from django.core.asgi import get_asgi_application env = environ.Env() environ.Env.read_env() -os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="{{ project_name }}.settings.production")) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="[[ project_name ]].settings.production")) application = get_asgi_application() diff --git a/djinit/templates/config/settings/base.j2 b/src/djinit/templates/config/settings/base.py-tpl similarity index 94% rename from djinit/templates/config/settings/base.j2 rename to src/djinit/templates/config/settings/base.py-tpl index 25797c9..ccdb901 100644 --- a/djinit/templates/config/settings/base.j2 +++ b/src/djinit/templates/config/settings/base.py-tpl @@ -29,8 +29,10 @@ THIRD_PARTY_APPS = [ ] USER_DEFINED_APPS = [ -{% for app in app_names %} "{{ app }}", -{% endfor %}] + # @LOOP app in app_names + "[[ app ]]", + # @ENDLOOP +] BUILT_IN_APPS = [ "django.contrib.admin", @@ -56,7 +58,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "{{ project_name }}.urls" +ROOT_URLCONF = "[[ project_name ]].urls" TEMPLATES = [ { @@ -73,7 +75,7 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = "{{ project_name }}.wsgi.application" +WSGI_APPLICATION = "[[ project_name ]].wsgi.application" # Password validation @@ -133,8 +135,8 @@ REST_FRAMEWORK = { # DRF Spectacular settings SPECTACULAR_SETTINGS = { - "TITLE": "{{ project_name }} API", - "DESCRIPTION": "API documentation for {{ project_name }}", + "TITLE": "[[ project_name ]] API", + "DESCRIPTION": "API documentation for [[ project_name ]]", "VERSION": "1.0.0", "SERVE_INCLUDE_SCHEMA": False, "COMPONENT_SPLIT_REQUEST": True, diff --git a/djinit/templates/config/settings/development.j2 b/src/djinit/templates/config/settings/development.py-tpl similarity index 97% rename from djinit/templates/config/settings/development.j2 rename to src/djinit/templates/config/settings/development.py-tpl index d787801..2e3c1b3 100644 --- a/djinit/templates/config/settings/development.j2 +++ b/src/djinit/templates/config/settings/development.py-tpl @@ -2,7 +2,7 @@ from .base import * -SECRET_KEY = "{{ secret_key }}" +SECRET_KEY = "[[ secret_key ]]" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/djinit/templates/config/settings/production.j2 b/src/djinit/templates/config/settings/production.py-tpl similarity index 79% rename from djinit/templates/config/settings/production.j2 rename to src/djinit/templates/config/settings/production.py-tpl index a2ab0f6..d22ba79 100644 --- a/djinit/templates/config/settings/production.j2 +++ b/src/djinit/templates/config/settings/production.py-tpl @@ -8,29 +8,29 @@ SECRET_KEY = env('SECRET_KEY') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) # Database -{% if use_database_url %} +# @IF use_database_url # Using dj_database_url (recommended for production) DATABASES = { 'default': env.db('DATABASE_URL') } -{% else %} +# @ELSE # Using individual database parameters DATABASES = { 'default': { - {% if database_type == 'mysql' %} + # @IF database_type == 'mysql' 'ENGINE': 'django.db.backends.mysql', 'PORT': env('DB_PORT', default='3306'), - {% else %} + # @ELSE 'ENGINE': 'django.db.backends.postgresql', 'PORT': env('DB_PORT', default='5432'), - {% endif %} + # @ENDIF 'NAME': env('DB_NAME'), 'USER': env('DB_USER'), 'PASSWORD': env('DB_PASSWORD'), 'HOST': env('DB_HOST'), } } -{% endif %} +# @ENDIF # CORS settings for production # Filter out empty strings from FRONTEND_URL @@ -42,13 +42,13 @@ CORS_ALLOW_PRIVATE_NETWORK = False # Email settings EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = env('EMAIL_HOST') +EMAIL_HOST = env('EMAIL_HOST', default="smtp.gmail.com") EMAIL_PORT = env.int('EMAIL_PORT', default=587) EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=True) -EMAIL_HOST_USER = env('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') -DEFAULT_FROM_EMAIL = env('EMAIL_HOST_USER') -SERVER_EMAIL = env('EMAIL_HOST_USER') +EMAIL_HOST_USER = env('EMAIL_HOST_USER', default="") +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default="") +DEFAULT_FROM_EMAIL = env('EMAIL_HOST_USER', default="") +SERVER_EMAIL = env('EMAIL_HOST_USER', default="") # Security settings for production SECURE_SSL_REDIRECT = True diff --git a/src/djinit/templates/config/urls.py-tpl b/src/djinit/templates/config/urls.py-tpl new file mode 100644 index 0000000..574294f --- /dev/null +++ b/src/djinit/templates/config/urls.py-tpl @@ -0,0 +1,103 @@ +# @IF url_type == 'project' +"""generated with djinit""" + +from django.contrib import admin +from django.urls import path +# @IF include_api or include_apps +from django.urls import include +# @ENDIF +# @IF include_api +from django.conf import settings +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, +) +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenBlacklistView, +) +# @ENDIF + +urlpatterns = [ + path("admin/", admin.site.urls), +# @IF include_api + # JWT tokens + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"), +# @ENDIF +# @IF include_api and comment_out_api_url + # path("api/", include("[[ api_module ]].urls")), +# @ENDIF +# @IF include_api and not comment_out_api_url + path("api/", include("[[ api_module ]].urls")), +# @ENDIF +# @IF include_apps + # App URLs +# @LOOP app in app_names + path("", include("[[ app ]].urls")), +# @ENDLOOP +# @ENDIF +] +# @IF include_api + +# Don't show schema in production +if settings.DEBUG: + urlpatterns += [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("docs/", SpectacularSwaggerView.as_view(url_name="schema")), + ] +# @ENDIF +# @ENDIF +# @IF url_type == 'api_root' +from django.urls import include, path + +app_name = "[[ app_name ]]" + +urlpatterns = [ + path("[[ version ]]/", include("[[ api_module ]].[[ version ]].urls")), +] +# @ENDIF +# @IF url_type == 'api_version' +from django.urls import include, path + +app_name = "[[ app_name ]]" + +urlpatterns = [ +# @LOOP app in app_list + path("[[ app ]]/", include("[[ app_module ]].[[ app ]].urls")), +# @ENDLOOP +] +# @ENDIF +# @IF url_type == 'drf_router' +from rest_framework.routers import DefaultRouter +# @IF has_viewset +from [[ app_module ]].views.[[ view_file ]] import [[ viewset_name ]] +# @ENDIF +# @IF has_app_name + +app_name = "[[ app_name ]]" +# @ENDIF + +router = DefaultRouter() +# @IF has_viewset +router.register(r"[[ router_prefix ]]", [[ viewset_name ]], basename="[[ basename ]]") +# @ENDIF + +urlpatterns = [ +# @IF has_include + path("", include(router.urls)), +# @ENDIF +] +# @IF not has_include +urlpatterns = router.urls +# @ENDIF +# @ENDIF +# @IF url_type == 'component' +from django.urls import path + +urlpatterns = [ + # Add your URL patterns here +] +# @ENDIF diff --git a/djinit/templates/config/wsgi.j2 b/src/djinit/templates/config/wsgi.py-tpl similarity index 79% rename from djinit/templates/config/wsgi.j2 rename to src/djinit/templates/config/wsgi.py-tpl index de26d9b..421456d 100644 --- a/djinit/templates/config/wsgi.j2 +++ b/src/djinit/templates/config/wsgi.py-tpl @@ -7,6 +7,6 @@ from django.core.wsgi import get_wsgi_application env = environ.Env() environ.Env.read_env() -os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="{{ project_name }}.settings.production")) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="[[ project_name ]].settings.production")) application = get_wsgi_application() diff --git a/djinit/templates/project/ci/github_actions.j2 b/src/djinit/templates/project/ci/github_actions-tpl similarity index 94% rename from djinit/templates/project/ci/github_actions.j2 rename to src/djinit/templates/project/ci/github_actions-tpl index 7be2bd5..5217107 100644 --- a/djinit/templates/project/ci/github_actions.j2 +++ b/src/djinit/templates/project/ci/github_actions-tpl @@ -62,7 +62,7 @@ jobs: - name: Run Django tests env: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost DB_NAME: test_db @@ -76,7 +76,7 @@ jobs: - name: Check migrations env: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost DB_NAME: test_db diff --git a/djinit/templates/project/ci/gitlab_ci.j2 b/src/djinit/templates/project/ci/gitlab_ci-tpl similarity index 84% rename from djinit/templates/project/ci/gitlab_ci.j2 rename to src/djinit/templates/project/ci/gitlab_ci-tpl index ff6ef5b..3d3bc61 100644 --- a/djinit/templates/project/ci/gitlab_ci.j2 +++ b/src/djinit/templates/project/ci/gitlab_ci-tpl @@ -1,4 +1,4 @@ -# GitLab CI/CD configuration for {{ project_name }} +# GitLab CI/CD configuration for [[ project_name ]] stages: - test @@ -36,7 +36,7 @@ test: stage: test image: python:3.13 variables: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -52,7 +52,7 @@ test:python310: stage: test image: python:3.10 variables: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -69,7 +69,7 @@ test:python311: stage: test image: python:3.11 variables: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -86,7 +86,7 @@ test:python312: stage: test image: python:3.12 variables: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -103,7 +103,7 @@ test:python313: stage: test image: python:3.13 variables: - DJANGO_SETTINGS_MODULE: {{ module_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ module_name ]].settings.development SECRET_KEY: test-secret-key-for-ci ALLOWED_HOSTS: localhost script: @@ -121,7 +121,7 @@ security: stage: security image: python:3.13 variables: - DJANGO_SETTINGS_MODULE: {{ project_name }}.settings.development + DJANGO_SETTINGS_MODULE: [[ project_name ]].settings.development SECRET_KEY: test-secret-key-for-ci script: - pip install uv diff --git a/djinit/templates/project/env_sample.j2 b/src/djinit/templates/project/env_sample-tpl similarity index 66% rename from djinit/templates/project/env_sample.j2 rename to src/djinit/templates/project/env_sample-tpl index 88215d0..c1a6cf7 100644 --- a/djinit/templates/project/env_sample.j2 +++ b/src/djinit/templates/project/env_sample-tpl @@ -3,21 +3,21 @@ DJANGO_SETTINGS_MODULE={{ project_name }}.settings.development SECRET_KEY=your-secret-key ALLOWED_HOSTS=api.your-domain.com,www.your-domain.com -{% if use_database_url %} +# @IF use_database_url # Database URL (recommended for production) -{% if database_type == 'mysql' %} -DATABASE_URL=mysql://user:password@localhost:3306/{{ project_name }} -{% else %} -DATABASE_URL=postgres://user:password@localhost:5432/{{ project_name }} -{% endif %} -{% else %} +# @IF database_type == 'mysql' +DATABASE_URL=mysql://user:password@localhost:3306/[[ project_name ]] +# @ELSE +DATABASE_URL=postgres://user:password@localhost:5432/[[ project_name ]] +# @ENDIF +# @ELSE # Database (individual parameters) DB_NAME= DB_USER= DB_PASSWORD= DB_HOST= DB_PORT= -{% endif %} +# @ENDIF # Frontend URL FRONTEND_URL=http://frontend.your-domain.com diff --git a/djinit/templates/project/gitignore.j2 b/src/djinit/templates/project/gitignore-tpl similarity index 100% rename from djinit/templates/project/gitignore.j2 rename to src/djinit/templates/project/gitignore-tpl diff --git a/djinit/services/__init__.py b/src/djinit/templates/project/init.py-tpl similarity index 100% rename from djinit/services/__init__.py rename to src/djinit/templates/project/init.py-tpl diff --git a/djinit/templates/project/justfile.j2 b/src/djinit/templates/project/justfile-tpl similarity index 95% rename from djinit/templates/project/justfile.j2 rename to src/djinit/templates/project/justfile-tpl index 3180917..a67dbd0 100644 --- a/djinit/templates/project/justfile.j2 +++ b/src/djinit/templates/project/justfile-tpl @@ -1,4 +1,4 @@ -# Justfile for {{ project_name }} +# Justfile for [[ project_name ]] # Task runner for Django development # Install just: https://github.com/casey/just @@ -108,15 +108,15 @@ dbshell: # Start production server with gunicorn server: - uv run gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:8000 + uv run gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:8000 # Start production server with gunicorn workers server-prod WORKERS="4": - uv run gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:8000 --workers {{ '{{' }}WORKERS{{ '}}' }} + uv run gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:8000 --workers {{ '{{' }}WORKERS{{ '}}' }} # Start production server with gunicorn and reload on file changes server-watch: - uv run gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:8000 --reload + uv run gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:8000 --reload # Clean Python cache files clean: diff --git a/djinit/templates/project/manage_py.j2 b/src/djinit/templates/project/manage.py-tpl similarity index 92% rename from djinit/templates/project/manage_py.j2 rename to src/djinit/templates/project/manage.py-tpl index b6a20b7..ba6f5c7 100644 --- a/djinit/templates/project/manage_py.j2 +++ b/src/djinit/templates/project/manage.py-tpl @@ -11,7 +11,7 @@ import environ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.getenv("DJANGO_SETTINGS_MODULE", "{{ project_name }}.settings.production")) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.getenv("DJANGO_SETTINGS_MODULE", "[[ project_name ]].settings.production")) try: from django.core.management import execute_from_command_line diff --git a/djinit/templates/project/procfile.j2 b/src/djinit/templates/project/procfile-tpl similarity index 68% rename from djinit/templates/project/procfile.j2 rename to src/djinit/templates/project/procfile-tpl index bb2c0db..fc01178 100644 --- a/djinit/templates/project/procfile.j2 +++ b/src/djinit/templates/project/procfile-tpl @@ -1,20 +1,20 @@ -# Procfile for {{ project_name }} +# Procfile for [[ project_name ]] # Used for deploying to PaaS platforms like Heroku, Railway, Render, etc. # Web server process (using Gunicorn) # Adjust workers based on your needs: (2 x CPU cores) + 1 -web: gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --timeout 120 --access-logfile - --error-logfile - --log-level info +web: gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:$PORT --workers 2 --timeout 120 --access-logfile - --error-logfile - --log-level info # Alternative web server configurations (uncomment to use): -# web: gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:$PORT --workers 4 --threads 2 --timeout 120 -# web: gunicorn {{ project_name }}.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --worker-class gevent --worker-connections 1000 --timeout 120 +# web: gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:$PORT --workers 4 --threads 2 --timeout 120 +# web: gunicorn [[ project_name ]].wsgi:application --bind 0.0.0.0:$PORT --workers 2 --worker-class gevent --worker-connections 1000 --timeout 120 # Release task (runs migrations before deployment) release: python manage.py migrate --noinput && python manage.py collectstatic --noinput # Uncomment below to run scheduled tasks (if using Celery) -# worker: celery -A {{ project_name }} worker --loglevel=info -# beat: celery -A {{ project_name }} beat --loglevel=info +# worker: celery -A [[ project_name ]] worker --loglevel=info +# beat: celery -A [[ project_name ]] beat --loglevel=info # Development server (for local testing with Foreman/Overmind) # web: python manage.py runserver 0.0.0.0:$PORT diff --git a/djinit/templates/project/pyproject_toml.j2 b/src/djinit/templates/project/pyproject.toml-tpl similarity index 83% rename from djinit/templates/project/pyproject_toml.j2 rename to src/djinit/templates/project/pyproject.toml-tpl index 28e3942..e9e49a3 100644 --- a/djinit/templates/project/pyproject_toml.j2 +++ b/src/djinit/templates/project/pyproject.toml-tpl @@ -1,5 +1,5 @@ [project] -name = "{{ package_name }}" +name = "[[ package_name ]]" version = "0.1.0" description = "" readme = "README.md" @@ -14,8 +14,12 @@ dependencies = [ "drf-spectacular", "django-cors-headers", "whitenoise", +# @IF database_type == 'mysql' "mysqlclient", +# @ENDIF +# @IF database_type == 'postgresql' "psycopg2-binary", +# @ENDIF "gunicorn", ] @@ -39,4 +43,4 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["{{ project_name }}"] +known-first-party = ["[[ project_name ]]"] diff --git a/djinit/templates/project/readme.j2 b/src/djinit/templates/project/readme.md-tpl similarity index 95% rename from djinit/templates/project/readme.j2 rename to src/djinit/templates/project/readme.md-tpl index 1aac23c..9c28632 100644 --- a/djinit/templates/project/readme.j2 +++ b/src/djinit/templates/project/readme.md-tpl @@ -1,4 +1,4 @@ -# {{ project_name }} +# [[ project_name ]] A Django project folder structure is generated with [djinitx/djinit/dj](https://github.com/S4NKALP/djinit). @@ -71,9 +71,9 @@ Run `just` to see all available commands, including: ## Project Structure ``` -{% if predefined_structure %} -{{ project_name }}/ -├── {{ module_name }}/ +# @IF predefined_structure +[[ project_name ]]/ +├── [[ module_name ]]/ │ ├── settings/ │ │ ├── base.py │ │ ├── development.py @@ -118,8 +118,8 @@ Run `just` to see all available commands, including: ├── Procfile ├── runtime.txt └── README.md -{% elif unified_structure %} -{{ project_name }}/ +# @ELSEIF unified_structure +[[ project_name ]]/ ├── apps/ │ ├── admin/ │ │ └── __init__.py @@ -158,9 +158,9 @@ Run `just` to see all available commands, including: ├── Procfile ├── runtime.txt └── README.md -{% else %} -{{ project_name }}/ -├── {{ module_name }}/ +# @ELSE +[[ project_name ]]/ +├── [[ module_name ]]/ │ ├── settings/ │ │ ├── base.py │ │ ├── development.py @@ -191,7 +191,7 @@ For example, if you have a `User` model in `models/user.py`, create: - `api/user/urls.py` - URL patterns for User endpoints See the README files in `api/` and `models/` directories for more details. -{% endif %} +# @ENDIF ``` ## API Documentation diff --git a/djinit/templates/project/requirements.j2 b/src/djinit/templates/project/requirements-tpl similarity index 74% rename from djinit/templates/project/requirements.j2 rename to src/djinit/templates/project/requirements-tpl index 381a0b7..cb48ff9 100644 --- a/djinit/templates/project/requirements.j2 +++ b/src/djinit/templates/project/requirements-tpl @@ -10,10 +10,10 @@ djangorestframework_simplejwt drf-spectacular django-cors-headers whitenoise -{% if database_type == 'mysql' %} +# @IF database_type == 'mysql' mysqlclient -{% else %} +# @ELSE psycopg[binary] -{% endif %} +# @ENDIF gunicorn -{% if use_database_url %}dj-database-url{% endif %} +dj-database-url # @IF use_database_url diff --git a/src/djinit/templates/project/runtime_txt-tpl b/src/djinit/templates/project/runtime_txt-tpl new file mode 100644 index 0000000..34ffefa --- /dev/null +++ b/src/djinit/templates/project/runtime_txt-tpl @@ -0,0 +1 @@ +python-[[ python_version ]] diff --git a/djinit/ui/__init__.py b/src/djinit/ui/__init__.py similarity index 100% rename from djinit/ui/__init__.py rename to src/djinit/ui/__init__.py diff --git a/djinit/ui/console.py b/src/djinit/ui/console.py similarity index 100% rename from djinit/ui/console.py rename to src/djinit/ui/console.py diff --git a/djinit/ui/input.py b/src/djinit/ui/input.py similarity index 98% rename from djinit/ui/input.py rename to src/djinit/ui/input.py index e0ff02d..7482a28 100644 --- a/djinit/ui/input.py +++ b/src/djinit/ui/input.py @@ -11,7 +11,7 @@ import questionary from djinit.core.types import ProjectMetadata from djinit.ui.console import UIColors, UIFormatter, console -from djinit.utils.common import get_package_name +from djinit.utils.common import CommonUtils from djinit.utils.validators import validate_app_name, validate_project_name @@ -178,7 +178,7 @@ class InputCollector: app_names: list[str] = [] # Default package_name to "backend" if project_dir is "." or empty - package_name = get_package_name(project_dir) + package_name = CommonUtils.get_package_name(project_dir) project_module_name = "config" if options["predefined"] else "core" if options["unified"] else None @@ -264,7 +264,7 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: # Generate Metadata if use_standard: - package_name = get_package_name(project_dir) + package_name = CommonUtils.get_package_name(project_dir) metadata = ProjectMetadata( package_name=package_name, use_github_actions=use_github, diff --git a/djinit/utils/__init__.py b/src/djinit/utils/__init__.py similarity index 100% rename from djinit/utils/__init__.py rename to src/djinit/utils/__init__.py diff --git a/src/djinit/utils/common.py b/src/djinit/utils/common.py new file mode 100644 index 0000000..ebd3f92 --- /dev/null +++ b/src/djinit/utils/common.py @@ -0,0 +1,340 @@ +""" +Shared utility functions. +Contains common operations used across multiple managers. +""" + +import os +import subprocess +import sys +from contextlib import contextmanager + +from djinit.core.base import BaseUtils +from djinit.templater import template_engine +from djinit.ui.console import UIFormatter + + +class CommonUtils(BaseUtils): + @staticmethod + def format_file(filename: str) -> None: + """Format Python file using Ruff formatter.""" + subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) + + @staticmethod + def create_file_with_content( + filename: str, content: str, success_message: str, should_format: bool = False + ) -> None: + from djinit.utils.exceptions import FileError + + try: + with open(filename, "w") as file: + file.write(content) + except OSError as e: + raise FileError(f"Failed to write file: {filename}", details=str(e)) from e + + if should_format: + CommonUtils.format_file(filename) + + UIFormatter.print_success(success_message) + + @staticmethod + def create_file_from_template( + file_path: str, + template_path: str, + context: dict, + success_message: str, + should_format: bool = True, + ) -> None: + try: + content = template_engine.render_template(template_path, context) + except Exception as e: + from djinit.utils.exceptions import TemplateError + + raise TemplateError(f"Failed to render template: {template_path}", details=str(e)) from e + + CommonUtils.create_file_with_content(file_path, content, success_message, should_format=should_format) + + @staticmethod + def create_init_file(directory: str, success_message: str) -> None: + init_path = os.path.join(directory, "__init__.py") + CommonUtils.create_file_from_template( + init_path, "project/init.py-tpl", {}, success_message, should_format=False + ) + + @staticmethod + def create_directory_with_init(dir_path: str, message: str) -> None: + """Create directory and __init__.py file.""" + os.makedirs(dir_path, exist_ok=True) + CommonUtils.create_init_file(dir_path, message) + + @staticmethod + def create_files_from_templates(base_dir: str, files: list, prefix: str = "", should_format: bool = False) -> None: + """Create multiple files from templates. + + Args: + base_dir: Base directory where files will be created + files: List of tuples (filename, template_path, context) + prefix: Optional prefix for success message + should_format: Whether to format the created files + """ + for file_spec in files: + if len(file_spec) == 3: + filename, template_path, context = file_spec + else: + # Support (filename, template_path) format with empty context + filename, template_path = file_spec + context = {} + filepath = os.path.join(base_dir, filename) + message = f"Created {prefix}{filename}" if prefix else f"Created {filename}" + CommonUtils.create_file_from_template( + filepath, template_path, context, message, should_format=should_format + ) + + @staticmethod + def get_package_name(project_dir: str) -> str: + """Get package name, defaulting to 'backend' if project_dir is '.' or empty.""" + return "backend" if project_dir == "." or not project_dir else project_dir + + @staticmethod + @contextmanager + def change_cwd(path: str): + original_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(original_cwd) + + @staticmethod + def _parse_app_entry_from_line(line: str) -> str | None: + """Extract app name from a line in USER_DEFINED_APPS section.""" + line_stripped = line.strip().strip(",").strip() + for quote in ('"', "'"): + if line_stripped.startswith(quote) and line_stripped.endswith(quote): + return line_stripped.strip(quote) + return None + + @staticmethod + def _is_user_apps_section_start(line: str) -> bool: + """Check if line starts USER_DEFINED_APPS section.""" + return "USER_DEFINED_APPS" in line and "=" in line + + @staticmethod + def _iterate_user_apps_lines(content: str): + """Generator that yields lines within USER_DEFINED_APPS section.""" + lines = content.split("\n") + in_user_apps = False + + for line in lines: + if CommonUtils._is_user_apps_section_start(line): + in_user_apps = True + elif in_user_apps and line.strip() == "]": + break + elif in_user_apps: + yield line + + @staticmethod + def extract_existing_apps(content: str) -> set: + existing_apps = set() + for line in CommonUtils._iterate_user_apps_lines(content): + app_name = CommonUtils._parse_app_entry_from_line(line) + if app_name: + existing_apps.add(app_name) + return existing_apps + + @staticmethod + def format_app_entries(apps: list) -> list: + return [f' "{app}",' for app in apps] + + @staticmethod + def is_app_section_start(line: str) -> bool: + return "=" in line and any( + keyword in line for keyword in ("BUILT_IN_APPS", "THIRD_PARTY_APPS", "INSTALLED_APPS") + ) + + @staticmethod + def is_user_apps_closing_bracket(lines: list, current_idx: int) -> bool: + for next_idx in range(current_idx + 1, min(current_idx + 3, len(lines))): + next_line = lines[next_idx].strip() + if next_line and "BUILT_IN_APPS" in next_line: + return True + return True # Default to True if we're inside USER_DEFINED_APPS + + @staticmethod + def split_bracket_line(line: str, apps_to_add: list) -> list: + bracket_pos = line.rfind("]") + before_bracket = line[:bracket_pos].rstrip() if bracket_pos > 0 else line.rstrip() + after_bracket = line[bracket_pos:] if bracket_pos > 0 else "]" + + result = [before_bracket] + result.extend(CommonUtils.format_app_entries(apps_to_add)) + result.append(after_bracket) + return result + + @staticmethod + def insert_apps_into_user_defined_apps(content: str, apps_to_add: list) -> str: + lines = content.split("\n") + new_lines = [] + in_user_apps = False + apps_added = False + + for i, line in enumerate(lines): + if CommonUtils._is_user_apps_section_start(line) and not apps_added: + in_user_apps = True + + if "]" in line: + new_lines.extend(CommonUtils.split_bracket_line(line, apps_to_add)) + apps_added = True + in_user_apps = False + else: + new_lines.append(line) + + elif in_user_apps and line.strip() == "]" and not apps_added: + if CommonUtils.is_user_apps_closing_bracket(lines, i): + new_lines.extend(CommonUtils.format_app_entries(apps_to_add)) + new_lines.append(line) + apps_added = True + in_user_apps = False + else: + new_lines.append(line) + + elif in_user_apps and CommonUtils.is_app_section_start(line) and not apps_added: + new_lines.extend(CommonUtils.format_app_entries(apps_to_add)) + new_lines.append("]") + new_lines.append(line) + apps_added = True + in_user_apps = False + + else: + new_lines.append(line) + + if not apps_added: + UIFormatter.print_error("Could not find or update USER_DEFINED_APPS section in base.py") + return None + + return "\n".join(new_lines) + + @staticmethod + def replace_app_in_user_defined_apps(content: str, old_app: str, new_app: str) -> str | None: + """Replace an app entry in USER_DEFINED_APPS with a new one.""" + lines = content.split("\n") + new_lines = [] + replaced = False + + for line in lines: + if not replaced and CommonUtils._parse_app_entry_from_line(line) == old_app: + # Try to preserve indentation + indent_idx = 0 + for char in line: + if char.isspace(): + indent_idx += 1 + else: + break + indent = line[:indent_idx] if indent_idx > 0 else " " + + new_lines.append(f'{indent}"{new_app}",') + replaced = True + else: + new_lines.append(line) + + if replaced: + return "\n".join(new_lines) + return None + + @staticmethod + def calculate_app_module_paths(app_names: list, metadata: dict) -> list: + nested = bool(metadata.get("nested_apps")) + nested_dir_name = metadata.get("nested_dir") if nested else None + return [CommonUtils.calculate_app_module_path(app_name, nested, nested_dir_name) for app_name in app_names] + + @staticmethod + def calculate_app_module_path(app_name: str, nested: bool, nested_dir: str | None) -> str: + return f"{nested_dir}.{app_name}" if nested and nested_dir else app_name + + @staticmethod + def is_django_project(directory: str = None) -> bool: + if directory is None: + directory = os.getcwd() + manage_py_path = os.path.join(directory, "manage.py") + return os.path.exists(manage_py_path) + + @staticmethod + def find_project_dir(search_dir: str = None) -> tuple[str | None, str | None]: + if search_dir is None: + search_dir = os.getcwd() + + for item in os.listdir(search_dir): + if os.path.isdir(item) and not item.startswith(".") and item != "__pycache__": + candidate = os.path.join(search_dir, item) + base_py = os.path.join(candidate, "settings", "base.py") + if os.path.exists(base_py): + return candidate, base_py + return None, None + + @staticmethod + def find_settings_path(search_dir: str = None) -> str | None: + project_dir, settings_base_path = CommonUtils.find_project_dir(search_dir) + if settings_base_path: + return os.path.dirname(settings_base_path) + return None + + @staticmethod + def get_base_settings_path(project_root: str, project_name: str) -> str: + settings_dir = os.path.join(project_root, project_name, "settings") + return os.path.join(settings_dir, "base.py") + + @staticmethod + def read_base_settings(project_root: str, project_name: str) -> str | None: + base_settings_path = CommonUtils.get_base_settings_path(project_root, project_name) + if not os.path.exists(base_settings_path): + return None + + with open(base_settings_path) as f: + return f.read() + + @staticmethod + def detect_nested_structure_from_settings( + base_settings_path: str, search_dir: str = None + ) -> tuple[bool, str | None, str]: + if search_dir is None: + search_dir = os.getcwd() + + if not os.path.exists(base_settings_path): + return False, None, search_dir + + try: + with open(base_settings_path) as f: + base_content = f.read() + + for line in CommonUtils._iterate_user_apps_lines(base_content): + app_label = CommonUtils._parse_app_entry_from_line(line) + if app_label and "." in app_label: + nested_dir_name = app_label.split(".", 1)[0] + nested_path = os.path.join(search_dir, nested_dir_name) + if os.path.isdir(nested_path): + return True, nested_dir_name, nested_path + except Exception: + pass + + return False, None, search_dir + + @staticmethod + def get_djinit_config(project_root: str = None) -> dict | None: + """Read .djinit configuration file.""" + import json + + if project_root is None: + project_root = os.getcwd() + + config_path = os.path.join(project_root, ".djinit") + if not os.path.exists(config_path): + # Try finding it in parent directories + parent = os.path.dirname(project_root) + if parent and parent != project_root: + return CommonUtils.get_djinit_config(parent) + return None + + try: + with open(config_path) as f: + return json.load(f) + except Exception: + return None diff --git a/src/djinit/utils/django.py b/src/djinit/utils/django.py new file mode 100644 index 0000000..13a6d28 --- /dev/null +++ b/src/djinit/utils/django.py @@ -0,0 +1,133 @@ +""" +Django helper module that replicates Django's startproject and startapp functionality. + +This module provides functions to create Django projects and apps without requiring Django. +""" + +import os + +from djinit.core.config import DJANGO_VERSION +from djinit.utils.common import CommonUtils + + +class DjangoHelper: + DJANGO_VERSION = DJANGO_VERSION + + @staticmethod + def startproject(project_name: str, directory: str, unified: bool = False, metadata: dict = None) -> None: + try: + from djinit.utils.secretkey import generate_secret_key + + os.makedirs(directory, exist_ok=True) + + manage_py_path = os.path.join(directory, "manage.py") + CommonUtils.create_file_from_template( + manage_py_path, "project/manage.py-tpl", {"project_name": project_name}, "Created manage.py" + ) + os.chmod(manage_py_path, 0o755) + + if unified: + return + + project_config_dir = os.path.join(directory, project_name) + CommonUtils.create_directory_with_init(project_config_dir, f"Created {project_name}/__init__.py") + + settings_dir = os.path.join(project_config_dir, "settings") + CommonUtils.create_directory_with_init(settings_dir, f"Created {project_name}/settings/__init__.py") + + # Generate secret key for development + secret_key = generate_secret_key() + metadata = metadata or {} + + base_context = { + "project_name": project_name, + "app_names": [], + "use_database_url": metadata.get("use_database_url", True), + "database_type": metadata.get("database_type", "postgresql"), + } + + dev_context = {"secret_key": secret_key} + + # Production context also needs database info + prod_context = base_context.copy() + + settings_files = [ + ("base.py", "config/settings/base.py-tpl", base_context), + ("development.py", "config/settings/development.py-tpl", dev_context), + ("production.py", "config/settings/production.py-tpl", prod_context), + ] + CommonUtils.create_files_from_templates(settings_dir, settings_files, f"{project_name}/settings/") + + urls_context = { + "url_type": "project", + "project_name": project_name, + "django_version": DjangoHelper.DJANGO_VERSION, + } + project_files = [ + ("urls.py", "config/urls.py-tpl", urls_context), + ("wsgi.py", "config/wsgi.py-tpl", {"project_name": project_name}), + ("asgi.py", "config/asgi.py-tpl", {"project_name": project_name}), + ] + CommonUtils.create_files_from_templates(project_config_dir, project_files, f"{project_name}/") + + except Exception as e: + from djinit.utils.exceptions import DjinitError + + raise DjinitError(f"Error creating Django project: {e}", details=str(e)) from e + + @staticmethod + def startapp(app_name: str, directory: str, app_module: str = None) -> bool: + """Create a Django app with standard structure (empty files, no example code). + + Args: + app_name: Name of the app to create + directory: Directory where the app should be created + app_module: Full module path (e.g., 'apps.users' for nested apps) + + Returns: + bool: True if successful, False otherwise + """ + try: + app_dir = os.path.join(directory, app_name) + os.makedirs(app_dir, exist_ok=True) + + # Prepare context + app_config_name = app_name.title().replace("_", "") + context = { + "app_name": app_name, + "app_config_name": app_config_name, + "django_version": DjangoHelper.DJANGO_VERSION, + } + + # Use provided app_module or default to app_name (path calculation handled by caller) + if not app_module: + app_module = app_name + + context["app_module"] = app_module + + # Create __init__.py + CommonUtils.create_init_file(app_dir, f"Created {app_name}/__init__.py") + + # Create all standard Django app files (empty templates with basic structure) + app_files = [ + ("apps.py", "components/apps.py-tpl", context), + ("models.py", "components/models.py-tpl", context), + ("views.py", "components/views.py-tpl", context), + ("admin.py", "components/admin.py-tpl", context), + ("serializers.py", "components/serializers.py-tpl", context), + ("urls.py", "components/urls.py-tpl", context), + ("tests.py", "components/tests.py-tpl", context), + ] + + CommonUtils.create_files_from_templates(app_dir, app_files, f"{app_name}/") + + # Create migrations directory + migrations_dir = os.path.join(app_dir, "migrations") + CommonUtils.create_directory_with_init(migrations_dir, f"Created {app_name}/migrations/__init__.py") + + return True + + except Exception as e: + from djinit.utils.exceptions import DjinitError + + raise DjinitError(f"Error creating Django app: {e}", details=str(e)) from e diff --git a/djinit/utils/exceptions.py b/src/djinit/utils/exceptions.py similarity index 100% rename from djinit/utils/exceptions.py rename to src/djinit/utils/exceptions.py diff --git a/djinit/utils/secretkey.py b/src/djinit/utils/secretkey.py similarity index 100% rename from djinit/utils/secretkey.py rename to src/djinit/utils/secretkey.py diff --git a/djinit/utils/validators.py b/src/djinit/utils/validators.py similarity index 100% rename from djinit/utils/validators.py rename to src/djinit/utils/validators.py diff --git a/uv.lock b/uv.lock index 129e592..4d6b15b 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -25,9 +46,9 @@ name = "djinitx" version = "0.3.3" source = { editable = "." } dependencies = [ - { name = "jinja2" }, { name = "questionary" }, { name = "rich" }, + { name = "typer" }, ] [package.dev-dependencies] @@ -38,9 +59,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "jinja2", specifier = ">=3.1.0" }, { name = "questionary", specifier = ">=2.0.1" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "typer", specifier = ">=0.21.1" }, ] [package.metadata.requires-dev] @@ -67,18 +88,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -91,91 +100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -355,6 +279,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"