gentem 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,746 @@
1
+ """Implementation of the `gentem fastapi` command."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from rich import print
8
+ from rich.panel import Panel
9
+
10
+ from gentem.utils.validators import (
11
+ ValidationError,
12
+ validate_db_type,
13
+ validate_project_name,
14
+ )
15
+
16
+
17
+ def generate_slug(name: str) -> str:
18
+ """Generate a URL-safe slug from the project name."""
19
+ return name.lower().replace("_", "-")
20
+
21
+
22
+ def generate_class_name(name: str) -> str:
23
+ """Generate a Python class name from the project name."""
24
+ return "".join(word.capitalize() for word in name.split("_"))
25
+
26
+
27
+ def generate_context(
28
+ project_name: str,
29
+ async_mode: bool,
30
+ db_type: Optional[str],
31
+ author: str,
32
+ description: str,
33
+ ) -> dict:
34
+ """Generate the template context for a FastAPI project.
35
+
36
+ Args:
37
+ project_name: Name of the project.
38
+ async_mode: Whether to use async mode.
39
+ db_type: Database type (asyncpg, etc.).
40
+ author: Author name.
41
+ description: Project description.
42
+
43
+ Returns:
44
+ Dictionary of template variables.
45
+ """
46
+ now = datetime.now()
47
+ slug = generate_slug(project_name)
48
+
49
+ return {
50
+ "project_name": project_name,
51
+ "project_slug": slug,
52
+ "class_name": generate_class_name(project_name),
53
+ "author": author or "Gentem User",
54
+ "email": f"user@{slug}.dev",
55
+ "description": description or f"A FastAPI project generated by Gentem.",
56
+ "version": "0.1.0",
57
+ "python_version": "3.10",
58
+ "python_versions": ["3.10", "3.11", "3.12"],
59
+ "async_mode": async_mode,
60
+ "db_type": db_type,
61
+ "has_database": db_type is not None,
62
+ "year": now.year,
63
+ "month": now.strftime("%B"),
64
+ }
65
+
66
+
67
+ def create_fastapi_project(
68
+ project_name: str,
69
+ async_mode: bool = False,
70
+ db_type: str = "",
71
+ author: str = "",
72
+ description: str = "",
73
+ dry_run: bool = False,
74
+ verbose: bool = False,
75
+ ) -> None:
76
+ """Create a new FastAPI project.
77
+
78
+ Args:
79
+ project_name: Name of the project to create.
80
+ async_mode: Use async mode with lifespan.
81
+ db_type: Database type (asyncpg for async SQLAlchemy).
82
+ author: Author name.
83
+ description: Project description.
84
+ dry_run: Preview without creating files.
85
+ verbose: Show verbose output.
86
+ """
87
+ # Validate inputs
88
+ try:
89
+ project_name = validate_project_name(project_name)
90
+ db_type = validate_db_type(db_type)
91
+ except ValidationError as e:
92
+ print(f"[red]Error: {e}[/]")
93
+ raise SystemExit(1)
94
+
95
+ # Generate template context
96
+ context = generate_context(
97
+ project_name=project_name,
98
+ async_mode=async_mode,
99
+ db_type=db_type,
100
+ author=author,
101
+ description=description,
102
+ )
103
+
104
+ # Determine output path
105
+ output_path = Path.cwd() / project_name
106
+
107
+ if verbose:
108
+ print(f"Output path: {output_path}")
109
+ print(f"Async mode: {async_mode}")
110
+ print(f"Database type: {db_type or 'none'}")
111
+
112
+ # Show summary
113
+ db_info = f"Database: {db_type}" if db_type else "No database"
114
+ print(Panel(
115
+ f"[bold]Creating new FastAPI project:[/] [cyan]{project_name}[/]\n"
116
+ f"[dim]Async:[/] {async_mode} | [dim]{db_info}[/]\n"
117
+ f"[dim]Author:[/] {context['author']}",
118
+ title="Gentem",
119
+ expand=False,
120
+ ))
121
+
122
+ if dry_run:
123
+ print("[yellow]DRY RUN - No files will be created[/]")
124
+ print(f"\nProject would be created at: {output_path}")
125
+ print("\nFiles that would be created:")
126
+ files = get_fastapi_project_files(project_name, db_type is not None)
127
+ for item in files:
128
+ print(f" - {item}")
129
+ return
130
+
131
+ # Create project files
132
+ try:
133
+ create_fastapi_project_files(
134
+ project_name=project_name,
135
+ context=context,
136
+ output_path=output_path,
137
+ async_mode=async_mode,
138
+ db_type=db_type,
139
+ )
140
+ print(f"\n[green]✓ FastAPI project '{project_name}' created successfully![/]")
141
+ print(f"\nNext steps:")
142
+ print(f" cd {project_name}")
143
+ print(f" pip install -r requirements.txt")
144
+ print(f" uvicorn {context['project_slug']}.main:app --reload")
145
+ except Exception as e:
146
+ print(f"[red]Error creating project: {e}[/]")
147
+ raise SystemExit(1)
148
+
149
+
150
+ def get_fastapi_project_files(project_name: str, has_database: bool) -> list[str]:
151
+ """Get list of files that would be created for a FastAPI project.
152
+
153
+ Args:
154
+ project_name: Name of the project.
155
+ has_database: Whether database support is included.
156
+
157
+ Returns:
158
+ List of file paths.
159
+ """
160
+ files = [
161
+ f"{project_name}/",
162
+ f"{project_name}/requirements.txt",
163
+ f"{project_name}/.env",
164
+ f"{project_name}/.gitignore",
165
+ f"{project_name}/README.md",
166
+ f"{project_name}/app/",
167
+ f"{project_name}/app/__init__.py",
168
+ f"{project_name}/app/main.py",
169
+ f"{project_name}/app/core/",
170
+ f"{project_name}/app/core/__init__.py",
171
+ f"{project_name}/app/core/config.py",
172
+ f"{project_name}/app/core/exceptions.py",
173
+ f"{project_name}/app/deps/",
174
+ f"{project_name}/app/deps/__init__.py",
175
+ f"{project_name}/app/utils/",
176
+ f"{project_name}/app/utils/__init__.py",
177
+ f"{project_name}/app/v1/",
178
+ f"{project_name}/app/v1/__init__.py",
179
+ f"{project_name}/app/v1/apis/",
180
+ f"{project_name}/app/v1/apis/__init__.py",
181
+ f"{project_name}/app/v1/apis/routes.py",
182
+ f"{project_name}/app/services/",
183
+ f"{project_name}/app/services/__init__.py",
184
+ f"{project_name}/app/schemas/",
185
+ f"{project_name}/app/schemas/__init__.py",
186
+ ]
187
+
188
+ if has_database:
189
+ files.extend([
190
+ f"{project_name}/app/models/",
191
+ f"{project_name}/app/models/__init__.py",
192
+ f"{project_name}/app/core/database.py",
193
+ ])
194
+
195
+ return files
196
+
197
+
198
+ def create_fastapi_project_files(
199
+ project_name: str,
200
+ context: dict,
201
+ output_path: Path,
202
+ async_mode: bool,
203
+ db_type: Optional[str],
204
+ ) -> None:
205
+ """Create all FastAPI project files.
206
+
207
+ Args:
208
+ project_name: Name of the project.
209
+ context: Template context.
210
+ output_path: Path to create the project at.
211
+ async_mode: Whether to use async mode.
212
+ db_type: Database type.
213
+ """
214
+ slug = context["project_slug"]
215
+
216
+ # Create directories
217
+ output_path.mkdir(parents=True, exist_ok=True)
218
+
219
+ # Create requirements.txt
220
+ requirements = [
221
+ "fastapi>=0.104.0",
222
+ "uvicorn[standard]>=0.24.0",
223
+ "pydantic>=2.5.0",
224
+ "pydantic-settings>=2.0.0",
225
+ "python-multipart>=0.0.6",
226
+ ]
227
+
228
+ if db_type == "asyncpg":
229
+ requirements.extend([
230
+ "sqlalchemy[asyncio]>=2.0.0",
231
+ "asyncpg>=0.29.0",
232
+ ])
233
+
234
+ requirements_content = "\n".join(sorted(requirements))
235
+
236
+ # Create .env
237
+ env_content = f"""# {project_name} Environment Variables
238
+
239
+ # Application
240
+ APP_NAME={project_name}
241
+ DEBUG=true
242
+ API_V1_STR=/api/v1
243
+
244
+ # Database
245
+ DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/{slug}
246
+ """
247
+
248
+ # Create .gitignore
249
+ gitignore_content = """# Byte-compiled / optimized / DLL files
250
+ __pycache__/
251
+ *.py[cod]
252
+ *$py.class
253
+
254
+ # C extensions
255
+ *.so
256
+
257
+ # Distribution / packaging
258
+ .Python
259
+ build/
260
+ develop-eggs/
261
+ dist/
262
+ downloads/
263
+ eggs/
264
+ .eggs/
265
+ lib/
266
+ lib64/
267
+ parts/
268
+ sdist/
269
+ var/
270
+ wheels/
271
+ *.egg-info/
272
+ .installed.cfg
273
+ *.egg
274
+
275
+ # PyInstaller
276
+ *.manifest
277
+ *.spec
278
+
279
+ # Installer logs
280
+ pip-log.txt
281
+ pip-delete-this-directory.txt
282
+
283
+ # Unit test / coverage reports
284
+ htmlcov/
285
+ .tox/
286
+ .nox/
287
+ .coverage
288
+ .coverage.*
289
+ .cache
290
+ nosetests.xml
291
+ coverage.xml
292
+ *.cover
293
+ *.py,cover
294
+ .hypothesis/
295
+ .pytest_cache/
296
+
297
+ # Jupyter Notebook
298
+ .ipynb_checkpoints
299
+
300
+ # IPython
301
+ profile_default/
302
+ ipython_config.py
303
+
304
+ # pyenv
305
+ .python-version
306
+
307
+ # pipenv
308
+ Pipfile.lock
309
+
310
+ # Environments
311
+ .env
312
+ .venv
313
+ env/
314
+ venv/
315
+ ENV/
316
+ env.bak/
317
+ venv.bak/
318
+
319
+ # IDEs
320
+ .vscode/
321
+ .idea/
322
+ *.swp
323
+ *.swo
324
+ *~
325
+
326
+ # OS
327
+ .DS_Store
328
+ Thumbs.db
329
+
330
+ # Logs
331
+ logs/
332
+ *.log
333
+ """
334
+
335
+ # Create README.md
336
+ async_note = "Async with lifespan" if async_mode else "Sync with lifespan"
337
+ db_note = "Database support with asyncpg + SQLAlchemy" if db_type == "asyncpg" else "No database"
338
+ models_note = "(with --db asyncpg)" if db_type == "asyncpg" else ""
339
+
340
+ readme_content = f"""# {project_name}
341
+
342
+ {context["description"]}
343
+
344
+ ## Features
345
+
346
+ - FastAPI REST API
347
+ - {async_note}
348
+ - {db_note}
349
+ - Project structure with core, deps, utils, v1/apis, services, schemas
350
+ - Exception handlers
351
+ - Middleware setup
352
+ - Health check endpoint
353
+
354
+ ## Installation
355
+
356
+ ```bash
357
+ pip install -r requirements.txt
358
+ ```
359
+
360
+ ## Running
361
+
362
+ ```bash
363
+ uvicorn {slug}.main:app --reload
364
+ ```
365
+
366
+ ## API Documentation
367
+
368
+ Once running, access:
369
+ - Swagger UI: http://localhost:8000/docs
370
+ - ReDoc: http://localhost:8000/redoc
371
+ - OpenAPI: http://localhost:8000/openapi.json
372
+
373
+ ## Project Structure
374
+
375
+ ```
376
+ app/
377
+ ├── __init__.py
378
+ ├── main.py # FastAPI application
379
+ ├── core/
380
+ │ ├── __init__.py
381
+ │ ├── config.py # Settings
382
+ │ └── exceptions.py # Custom exceptions
383
+ ├── deps/
384
+ │ └── __init__.py # Dependencies
385
+ ├── utils/
386
+ │ └── __init__.py # Utility functions
387
+ ├── v1/
388
+ │ ├── __init__.py
389
+ │ └── apis/
390
+ │ ├── __init__.py
391
+ │ └── routes.py # API routes
392
+ ├── services/
393
+ │ └── __init__.py # Business logic
394
+ ├── schemas/
395
+ │ └── __init__.py # Pydantic schemas
396
+ └── models/
397
+ └── __init__.py # SQLAlchemy models {models_note}
398
+ ```
399
+
400
+ ## License
401
+
402
+ This project is licensed under the MIT License.
403
+ """
404
+
405
+ # Write common files
406
+ (output_path / "requirements.txt").write_text(requirements_content, encoding="utf-8")
407
+ (output_path / ".env").write_text(env_content, encoding="utf-8")
408
+ (output_path / ".gitignore").write_text(gitignore_content, encoding="utf-8")
409
+ (output_path / "README.md").write_text(readme_content, encoding="utf-8")
410
+
411
+ # Create app directory structure
412
+ app_dir = output_path / "app"
413
+ app_dir.mkdir(parents=True, exist_ok=True)
414
+ (app_dir / "__init__.py").write_text(f'""" {project_name} - FastAPI application."""\n', encoding="utf-8")
415
+
416
+ # Create core module
417
+ core_dir = app_dir / "core"
418
+ core_dir.mkdir(parents=True, exist_ok=True)
419
+ (core_dir / "__init__.py").write_text('"""Core module: config, exceptions, etc."""\n', encoding="utf-8")
420
+
421
+ # Create config.py
422
+ config_py = f'''"""Application configuration."""
423
+
424
+ from pydantic import Field
425
+ from pydantic_settings import BaseSettings, SettingsConfigDict
426
+
427
+
428
+ class Settings(BaseSettings):
429
+ """Application settings."""
430
+
431
+ model_config = SettingsConfigDict(
432
+ env_file=".env",
433
+ env_file_encoding="utf-8",
434
+ extra="ignore",
435
+ )
436
+
437
+ app_name: str = Field(default="{project_name}", description="Application name")
438
+ debug: bool = Field(default=False, description="Debug mode")
439
+ api_v1_str: str = Field(default="/api/v1", description="API version 1 prefix")
440
+ database_url: str = Field(default="", description="Database connection URL")
441
+
442
+
443
+ settings = Settings()
444
+ '''
445
+ (core_dir / "config.py").write_text(config_py, encoding="utf-8")
446
+
447
+ # Create exceptions.py
448
+ exceptions_py = '''"""Custom exceptions and exception handlers."""
449
+
450
+ from fastapi import HTTPException, Request
451
+ from fastapi.responses import JSONResponse
452
+ from typing import Any, Dict
453
+
454
+
455
+ class AppException(Exception):
456
+ """Base application exception."""
457
+
458
+ def __init__(self, message: str, status_code: int = 500, details: Dict[str, Any] = None):
459
+ self.message = message
460
+ self.status_code = status_code
461
+ self.details = details or {}
462
+
463
+
464
+ class NotFoundException(AppException):
465
+ """Resource not found."""
466
+
467
+ def __init__(self, resource: str = "Resource"):
468
+ super().__init__(
469
+ message=f"{resource} not found",
470
+ status_code=404,
471
+ details={"resource": resource},
472
+ )
473
+
474
+
475
+ class ValidationException(AppException):
476
+ """Validation error."""
477
+
478
+ def __init__(self, message: str, details: Dict[str, Any] = None):
479
+ super().__init__(
480
+ message=message,
481
+ status_code=422,
482
+ details=details,
483
+ )
484
+
485
+
486
+ async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
487
+ """Handle AppException."""
488
+ return JSONResponse(
489
+ status_code=exc.status_code,
490
+ content={
491
+ "error": {
492
+ "message": exc.message,
493
+ "details": exc.details,
494
+ }
495
+ },
496
+ )
497
+
498
+
499
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
500
+ """Handle HTTPException."""
501
+ return JSONResponse(
502
+ status_code=exc.status_code,
503
+ content={
504
+ "error": {
505
+ "message": str(exc.detail) if exc.detail else "HTTP Error",
506
+ }
507
+ },
508
+ )
509
+
510
+
511
+ async def validation_exception_handler(request: Request, exc) -> JSONResponse:
512
+ """Handle validation errors from Pydantic."""
513
+ return JSONResponse(
514
+ status_code=422,
515
+ content={
516
+ "error": {
517
+ "message": "Validation error",
518
+ "details": exc.errors() if hasattr(exc, "errors") else str(exc),
519
+ }
520
+ },
521
+ )
522
+
523
+
524
+ def setup_exception_handlers(app) -> None:
525
+ """Setup exception handlers for the FastAPI app."""
526
+ app.add_exception_handler(AppException, app_exception_handler)
527
+ app.add_exception_handler(HTTPException, http_exception_handler)
528
+ app.add_exception_handler(ValidationException, validation_exception_handler)
529
+ '''
530
+ (core_dir / "exceptions.py").write_text(exceptions_py, encoding="utf-8")
531
+
532
+ # Create deps module
533
+ deps_dir = app_dir / "deps"
534
+ deps_dir.mkdir(parents=True, exist_ok=True)
535
+ (deps_dir / "__init__.py").write_text('"""Dependencies."""\n', encoding="utf-8")
536
+
537
+ # Create utils module
538
+ utils_dir = app_dir / "utils"
539
+ utils_dir.mkdir(parents=True, exist_ok=True)
540
+ (utils_dir / "__init__.py").write_text('"""Utility functions."""\n', encoding="utf-8")
541
+
542
+ # Create v1 module
543
+ v1_dir = app_dir / "v1"
544
+ v1_dir.mkdir(parents=True, exist_ok=True)
545
+ (v1_dir / "__init__.py").write_text('"""API version 1."""\n', encoding="utf-8")
546
+
547
+ # Create v1/apis module
548
+ apis_dir = v1_dir / "apis"
549
+ apis_dir.mkdir(parents=True, exist_ok=True)
550
+ (apis_dir / "__init__.py").write_text('"""API routes."""\n', encoding="utf-8")
551
+
552
+ # Create routes.py
553
+ routes_py = f'''"""API routes."""
554
+
555
+ from fastapi import APIRouter
556
+ from typing import Any, Dict
557
+
558
+ router = APIRouter()
559
+
560
+
561
+ @router.get("/healthz", tags=["Health"])
562
+ async def health_check() -> Dict[str, str]:
563
+ """Health check endpoint."""
564
+ return {{"status": "healthy"}}
565
+
566
+
567
+ @router.get("/", tags=["Root"])
568
+ async def root() -> Dict[str, Any]:
569
+ """Root endpoint."""
570
+ return {{
571
+ "app": "{project_name}",
572
+ "version": "{context["version"]}",
573
+ "docs": "/docs",
574
+ }}
575
+ '''
576
+ (apis_dir / "routes.py").write_text(routes_py, encoding="utf-8")
577
+
578
+ # Create services module
579
+ services_dir = app_dir / "services"
580
+ services_dir.mkdir(parents=True, exist_ok=True)
581
+ (services_dir / "__init__.py").write_text('"""Business logic services."""\n', encoding="utf-8")
582
+
583
+ # Create schemas module
584
+ schemas_dir = app_dir / "schemas"
585
+ schemas_dir.mkdir(parents=True, exist_ok=True)
586
+ (schemas_dir / "__init__.py").write_text('"""Pydantic schemas."""\n', encoding="utf-8")
587
+
588
+ # Create database module if db_type is set
589
+ if db_type == "asyncpg":
590
+ database_py = f'''"""Database configuration and session management."""
591
+
592
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
593
+ from sqlalchemy.orm import DeclarativeBase
594
+
595
+ from app.core.config import settings
596
+
597
+ # Create async engine
598
+ engine = create_async_engine(
599
+ settings.database_url,
600
+ echo=settings.debug,
601
+ pool_pre_ping=True,
602
+ )
603
+
604
+ # Create async session factory
605
+ async_session_factory = async_sessionmaker(
606
+ engine,
607
+ class_=AsyncSession,
608
+ expire_on_commit=False,
609
+ )
610
+
611
+
612
+ class Base(DeclarativeBase):
613
+ """Base class for SQLAlchemy models."""
614
+ pass
615
+
616
+
617
+ async def get_db() -> AsyncSession:
618
+ """Dependency that provides a database session."""
619
+ async with async_session_factory() as session:
620
+ try:
621
+ yield session
622
+ finally:
623
+ await session.close()
624
+
625
+
626
+ async def init_db() -> None:
627
+ """Initialize database tables."""
628
+ async with engine.begin() as conn:
629
+ await conn.run_sync(Base.metadata.create_all)
630
+
631
+
632
+ async def close_db() -> None:
633
+ """Close database connections."""
634
+ await engine.dispose()
635
+ '''
636
+ (core_dir / "database.py").write_text(database_py, encoding="utf-8")
637
+
638
+ # Create models module
639
+ models_dir = app_dir / "models"
640
+ models_dir.mkdir(parents=True, exist_ok=True)
641
+ (models_dir / "__init__.py").write_text('''"""SQLAlchemy models."""
642
+ from app.core.database import Base
643
+
644
+ # Import models here
645
+ # Example:
646
+ # class User(Base):
647
+ # __tablename__ = "users"
648
+ # id = Column(Integer, primary_key=True, index=True)
649
+ # email = Column(String, unique=True, index=True)
650
+ ''', encoding="utf-8")
651
+ else:
652
+ # Create empty models __init__.py
653
+ models_dir = app_dir / "models"
654
+ models_dir.mkdir(parents=True, exist_ok=True)
655
+ (models_dir / "__init__.py").write_text('''"""SQLAlchemy models."""
656
+ # Add models here
657
+ # Example:
658
+ # from app.core.database import Base
659
+ # from sqlalchemy import Column, Integer, String
660
+ #
661
+ # class User(Base):
662
+ # __tablename__ = "users"
663
+ # id = Column(Integer, primary_key=True, index=True)
664
+ ''', encoding="utf-8")
665
+
666
+ # Create main.py
667
+ if async_mode:
668
+ lifespan_section = '''
669
+ from contextlib import asynccontextmanager
670
+
671
+ from app.core.database import init_db, close_db
672
+
673
+
674
+ @asynccontextmanager
675
+ async def lifespan(app):
676
+ """Lifespan context manager for startup and shutdown events."""
677
+ # Startup
678
+ print("Starting up...")
679
+ await init_db()
680
+ yield
681
+ # Shutdown
682
+ print("Shutting down...")
683
+ await close_db()
684
+ '''
685
+ lifespan_arg = "lifespan=lifespan"
686
+ else:
687
+ lifespan_section = '''
688
+ # No lifespan - simple startup
689
+ '''
690
+ lifespan_arg = ""
691
+
692
+ main_py = f'''"""FastAPI application entry point."""
693
+
694
+ from fastapi import FastAPI
695
+ from fastapi.middleware.cors import CORSMiddleware
696
+
697
+ from app.core.config import settings
698
+ from app.core.exceptions import setup_exception_handlers
699
+ from app.v1.apis.routes import router as v1_router
700
+ {lifespan_section}
701
+
702
+
703
+ def create_app() -> FastAPI:
704
+ """Create and configure the FastAPI application."""
705
+ app = FastAPI(
706
+ title=settings.app_name,
707
+ description="{context["description"]}",
708
+ version="{context["version"]}",
709
+ docs_url="/docs",
710
+ redoc_url="/redoc",
711
+ openapi_url="/openapi.json",
712
+ {lifespan_arg}
713
+ )
714
+
715
+ # CORS middleware
716
+ app.add_middleware(
717
+ CORSMiddleware,
718
+ allow_origins=["*"],
719
+ allow_credentials=True,
720
+ allow_methods=["*"],
721
+ allow_headers=["*"],
722
+ )
723
+
724
+ # Setup exception handlers
725
+ setup_exception_handlers(app)
726
+
727
+ # Include API routers
728
+ app.include_router(v1_router, prefix=settings.api_v1_str)
729
+
730
+ return app
731
+
732
+
733
+ app = create_app()
734
+
735
+
736
+ if __name__ == "__main__":
737
+ import uvicorn
738
+
739
+ uvicorn.run(
740
+ "app.main:app",
741
+ host="0.0.0.0",
742
+ port=8000,
743
+ reload=settings.debug,
744
+ )
745
+ '''
746
+ (app_dir / "main.py").write_text(main_py, encoding="utf-8")