fastapi-spawn 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. fastapi_spawn/__init__.py +6 -0
  2. fastapi_spawn/cli.py +387 -0
  3. fastapi_spawn/config.py +162 -0
  4. fastapi_spawn/constants.py +133 -0
  5. fastapi_spawn/generator.py +294 -0
  6. fastapi_spawn/interactive.py +192 -0
  7. fastapi_spawn/templates/alembic/alembic.ini.j2 +39 -0
  8. fastapi_spawn/templates/alembic/env.py.j2 +64 -0
  9. fastapi_spawn/templates/app/__init__.py.j2 +1 -0
  10. fastapi_spawn/templates/app/api/deps.py.j2 +39 -0
  11. fastapi_spawn/templates/app/api/v1/auth.py.j2 +59 -0
  12. fastapi_spawn/templates/app/api/v1/health.py.j2 +40 -0
  13. fastapi_spawn/templates/app/core/ai.py.j2 +76 -0
  14. fastapi_spawn/templates/app/core/config.py.j2 +177 -0
  15. fastapi_spawn/templates/app/core/exceptions.py.j2 +43 -0
  16. fastapi_spawn/templates/app/core/logging.py.j2 +70 -0
  17. fastapi_spawn/templates/app/core/security.py.j2 +42 -0
  18. fastapi_spawn/templates/app/core/storage.py.j2 +73 -0
  19. fastapi_spawn/templates/app/db/session.py.j2 +84 -0
  20. fastapi_spawn/templates/app/main.py.j2 +71 -0
  21. fastapi_spawn/templates/base/Makefile.j2 +45 -0
  22. fastapi_spawn/templates/base/README.md.j2 +74 -0
  23. fastapi_spawn/templates/base/env.j2 +82 -0
  24. fastapi_spawn/templates/base/env_example.j2 +85 -0
  25. fastapi_spawn/templates/base/gitignore.j2 +38 -0
  26. fastapi_spawn/templates/base/pre_commit.j2 +17 -0
  27. fastapi_spawn/templates/base/pyproject.toml.j2 +129 -0
  28. fastapi_spawn/templates/ci/github/publish.yml.j2 +32 -0
  29. fastapi_spawn/templates/ci/github/tests.yml.j2 +39 -0
  30. fastapi_spawn/templates/ci/gitlab/gitlab-ci.yml.j2 +29 -0
  31. fastapi_spawn/templates/docker/Dockerfile.j2 +17 -0
  32. fastapi_spawn/templates/docker/docker-compose.yml.j2 +97 -0
  33. fastapi_spawn/templates/docker/dockerignore.j2 +13 -0
  34. fastapi_spawn/templates/infra/docker/docker-compose.prod.yml.j2 +43 -0
  35. fastapi_spawn/templates/infra/helm/Chart.yaml.j2 +6 -0
  36. fastapi_spawn/templates/infra/helm/values.yaml.j2 +26 -0
  37. fastapi_spawn/templates/infra/terraform/main.tf.j2 +26 -0
  38. fastapi_spawn/templates/infra/terraform/variables.tf.j2 +17 -0
  39. fastapi_spawn/templates/root/main.py.j2 +16 -0
  40. fastapi_spawn/templates/tasks/celery_app.py.j2 +37 -0
  41. fastapi_spawn/templates/tasks/sample_tasks.py.j2 +27 -0
  42. fastapi_spawn/templates/tests/conftest.py.j2 +22 -0
  43. fastapi_spawn/templates/tests/test_health.py.j2 +30 -0
  44. fastapi_spawn/utils.py +58 -0
  45. fastapi_spawn/validators.py +67 -0
  46. fastapi_spawn-0.1.0.dist-info/METADATA +262 -0
  47. fastapi_spawn-0.1.0.dist-info/RECORD +50 -0
  48. fastapi_spawn-0.1.0.dist-info/WHEEL +4 -0
  49. fastapi_spawn-0.1.0.dist-info/entry_points.txt +2 -0
  50. fastapi_spawn-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,40 @@
1
+ """Health check endpoints — /health, /readiness, /liveness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from fastapi import APIRouter
8
+ from pydantic import BaseModel
9
+
10
+ router = APIRouter(prefix="/health")
11
+
12
+ _start_time = time.time()
13
+
14
+
15
+ class HealthResponse(BaseModel):
16
+ status: str
17
+ uptime_seconds: float
18
+ version: str = "0.1.0"
19
+ service: str = "{{ project_name }}"
20
+
21
+
22
+ @router.get("", response_model=HealthResponse, summary="Basic health check")
23
+ async def health() -> HealthResponse:
24
+ """Returns service status and uptime."""
25
+ return HealthResponse(
26
+ status="ok",
27
+ uptime_seconds=round(time.time() - _start_time, 2),
28
+ )
29
+
30
+
31
+ @router.get("/readiness", summary="Readiness probe (Kubernetes)")
32
+ async def readiness() -> dict:
33
+ """Indicates whether the service is ready to accept traffic."""
34
+ return {"status": "ready"}
35
+
36
+
37
+ @router.get("/liveness", summary="Liveness probe (Kubernetes)")
38
+ async def liveness() -> dict:
39
+ """Indicates whether the service process is alive."""
40
+ return {"status": "alive"}
@@ -0,0 +1,76 @@
1
+ """AI client utilities for {{ project_name }}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ {% if ai == "openai" %}
8
+ from openai import AsyncOpenAI
9
+
10
+ from app.core.config import settings
11
+
12
+
13
+ @lru_cache
14
+ def get_openai_client() -> AsyncOpenAI:
15
+ """Return a cached AsyncOpenAI client."""
16
+ return AsyncOpenAI(
17
+ api_key=settings.OPENAI_API_KEY,
18
+ base_url=settings.OPENAI_BASE_URL or None, # None = default api.openai.com
19
+ )
20
+
21
+
22
+ async def chat_completion(
23
+ messages: list[dict],
24
+ model: str | None = None,
25
+ temperature: float = 0.7,
26
+ max_tokens: int = 1024,
27
+ ) -> str:
28
+ """Send a chat completion request and return the response text."""
29
+ client = get_openai_client()
30
+ response = await client.chat.completions.create(
31
+ model=model or settings.OPENAI_MODEL,
32
+ messages=messages,
33
+ temperature=temperature,
34
+ max_tokens=max_tokens,
35
+ )
36
+ return response.choices[0].message.content or ""
37
+
38
+
39
+ async def get_embedding(text: str, model: str | None = None) -> list[float]:
40
+ """Return an embedding vector for the given text."""
41
+ client = get_openai_client()
42
+ response = await client.embeddings.create(
43
+ model=model or settings.OPENAI_EMBEDDING_MODEL,
44
+ input=text,
45
+ )
46
+ return response.data[0].embedding
47
+
48
+ {% elif ai == "anthropic" %}
49
+ import anthropic
50
+
51
+ from app.core.config import settings
52
+
53
+
54
+ @lru_cache
55
+ def get_anthropic_client() -> anthropic.AsyncAnthropic:
56
+ """Return a cached AsyncAnthropic client."""
57
+ return anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
58
+
59
+
60
+ async def chat_completion(
61
+ messages: list[dict],
62
+ system: str = "You are a helpful assistant.",
63
+ model: str | None = None,
64
+ max_tokens: int = 1024,
65
+ ) -> str:
66
+ """Send a message to Claude and return the response text."""
67
+ client = get_anthropic_client()
68
+ response = await client.messages.create(
69
+ model=model or settings.ANTHROPIC_MODEL,
70
+ max_tokens=max_tokens,
71
+ system=system,
72
+ messages=messages,
73
+ )
74
+ return response.content[0].text
75
+
76
+ {% endif %}
@@ -0,0 +1,177 @@
1
+ """Application configuration using Pydantic Settings v2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from typing import Any
7
+
8
+ from pydantic import field_validator
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ model_config = SettingsConfigDict(
14
+ env_file=".env",
15
+ env_file_encoding="utf-8",
16
+ case_sensitive=False,
17
+ extra="ignore",
18
+ )
19
+
20
+ # ── App ────────────────────────────────────────────────────────────────
21
+ APP_NAME: str = "{{ project_name }}"
22
+ APP_ENV: str = "development"
23
+ DEBUG: bool = False
24
+ API_V1_PREFIX: str = "/api/v1"
25
+ SECRET_KEY: str = "change-me"
26
+ CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
27
+
28
+ @field_validator("CORS_ORIGINS", mode="before")
29
+ @classmethod
30
+ def assemble_cors_origins(cls, v: Any) -> list[str]:
31
+ if isinstance(v, str):
32
+ return [o.strip() for o in v.split(",")]
33
+ return v
34
+
35
+ {% if db == "postgresql" %}
36
+ # ── PostgreSQL ─────────────────────────────────────────────────────────
37
+ POSTGRES_USER: str = "postgres"
38
+ POSTGRES_PASSWORD: str = "postgres"
39
+ POSTGRES_HOST: str = "localhost"
40
+ POSTGRES_PORT: str = "5432"
41
+ POSTGRES_DB: str = "{{ slug }}_db"
42
+
43
+ @property
44
+ def database_url(self) -> str:
45
+ return (
46
+ f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
47
+ f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
48
+ )
49
+
50
+ {% elif db == "mysql" %}
51
+ # ── MySQL ──────────────────────────────────────────────────────────────
52
+ MYSQL_USER: str = "root"
53
+ MYSQL_PASSWORD: str = "mysql"
54
+ MYSQL_HOST: str = "localhost"
55
+ MYSQL_PORT: str = "3306"
56
+ MYSQL_DB: str = "{{ slug }}_db"
57
+
58
+ @property
59
+ def database_url(self) -> str:
60
+ return (
61
+ f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}"
62
+ f"@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
63
+ )
64
+
65
+ {% elif db == "sqlite" %}
66
+ # ── SQLite ─────────────────────────────────────────────────────────────
67
+ SQLITE_FILE: str = "{{ slug }}.db"
68
+
69
+ @property
70
+ def database_url(self) -> str:
71
+ return f"sqlite+aiosqlite:///./{self.SQLITE_FILE}"
72
+
73
+ {% endif %}
74
+ {% if has_mongo %}
75
+ # ── MongoDB ────────────────────────────────────────────────────────────
76
+ MONGODB_USER: str = "mongo"
77
+ MONGODB_PASSWORD: str = "mongo"
78
+ MONGODB_HOST: str = "localhost"
79
+ MONGODB_PORT: str = "27017"
80
+ MONGODB_DB: str = "{{ slug }}_db"
81
+
82
+ @property
83
+ def mongodb_url(self) -> str:
84
+ if self.MONGODB_USER and self.MONGODB_PASSWORD:
85
+ return (
86
+ f"mongodb://{self.MONGODB_USER}:{self.MONGODB_PASSWORD}"
87
+ f"@{self.MONGODB_HOST}:{self.MONGODB_PORT}"
88
+ )
89
+ return f"mongodb://{self.MONGODB_HOST}:{self.MONGODB_PORT}"
90
+
91
+ {% endif %}
92
+ {% if broker == "redis" or cache == "redis" %}
93
+ # ── Redis ──────────────────────────────────────────────────────────────
94
+ REDIS_HOST: str = "localhost"
95
+ REDIS_PORT: str = "6379"
96
+ REDIS_PASSWORD: str = ""
97
+ REDIS_DB: str = "0"
98
+
99
+ @property
100
+ def redis_url(self) -> str:
101
+ if self.REDIS_PASSWORD:
102
+ return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
103
+ return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
104
+
105
+ {% endif %}
106
+ {% if broker == "rabbitmq" %}
107
+ # ── RabbitMQ ───────────────────────────────────────────────────────────
108
+ RABBITMQ_USER: str = "guest"
109
+ RABBITMQ_PASSWORD: str = "guest"
110
+ RABBITMQ_HOST: str = "localhost"
111
+ RABBITMQ_PORT: str = "5672"
112
+ RABBITMQ_VHOST: str = "/"
113
+
114
+ @property
115
+ def rabbitmq_url(self) -> str:
116
+ return (
117
+ f"amqp://{self.RABBITMQ_USER}:{self.RABBITMQ_PASSWORD}"
118
+ f"@{self.RABBITMQ_HOST}:{self.RABBITMQ_PORT}/{self.RABBITMQ_VHOST}"
119
+ )
120
+
121
+ {% endif %}
122
+ {% if broker == "kafka" %}
123
+ # ── Kafka ──────────────────────────────────────────────────────────────
124
+ KAFKA_HOST: str = "localhost"
125
+ KAFKA_PORT: str = "9092"
126
+
127
+ @property
128
+ def kafka_bootstrap_servers(self) -> str:
129
+ return f"{self.KAFKA_HOST}:{self.KAFKA_PORT}"
130
+
131
+ {% endif %}
132
+ {% if has_s3 %}
133
+ # ── AWS S3 ─────────────────────────────────────────────────────────────
134
+ AWS_ACCESS_KEY_ID: str = ""
135
+ AWS_SECRET_ACCESS_KEY: str = ""
136
+ AWS_REGION: str = "us-east-1"
137
+ AWS_S3_BUCKET: str = "{{ slug }}-bucket"
138
+ AWS_S3_ENDPOINT_URL: str = "" # Override for MinIO / LocalStack
139
+
140
+ {% endif %}
141
+ {% if has_auth %}
142
+ # ── Auth ───────────────────────────────────────────────────────────────
143
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
144
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
145
+ ALGORITHM: str = "HS256"
146
+
147
+ {% endif %}
148
+ {% if ai == "openai" %}
149
+ # ── OpenAI ─────────────────────────────────────────────────────────────
150
+ OPENAI_API_KEY: str = ""
151
+ OPENAI_MODEL: str = "gpt-4o"
152
+ OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
153
+ OPENAI_BASE_URL: str = "" # Override for Azure OpenAI, LM Studio, local endpoints
154
+
155
+ {% elif ai == "anthropic" %}
156
+ # ── Anthropic ──────────────────────────────────────────────────────────
157
+ ANTHROPIC_API_KEY: str = ""
158
+ ANTHROPIC_MODEL: str = "claude-3-5-sonnet-20241022"
159
+
160
+ {% endif %}
161
+
162
+ # ── Helpers ────────────────────────────────────────────────────────────
163
+ @property
164
+ def is_development(self) -> bool:
165
+ return self.APP_ENV == "development"
166
+
167
+ @property
168
+ def is_production(self) -> bool:
169
+ return self.APP_ENV == "production"
170
+
171
+
172
+ @lru_cache
173
+ def get_settings() -> Settings:
174
+ return Settings()
175
+
176
+
177
+ settings: Settings = get_settings()
@@ -0,0 +1,43 @@
1
+ """Global exception handlers."""
2
+
3
+ from fastapi import FastAPI, Request, status
4
+ from fastapi.responses import JSONResponse
5
+
6
+
7
+ class AppError(Exception):
8
+ """Base application error."""
9
+ def __init__(self, message: str, status_code: int = 400) -> None:
10
+ self.message = message
11
+ self.status_code = status_code
12
+ super().__init__(message)
13
+
14
+
15
+ class NotFoundError(AppError):
16
+ def __init__(self, resource: str = "Resource") -> None:
17
+ super().__init__(f"{resource} not found.", status_code=404)
18
+
19
+
20
+ class UnauthorizedError(AppError):
21
+ def __init__(self, detail: str = "Not authenticated.") -> None:
22
+ super().__init__(detail, status_code=401)
23
+
24
+
25
+ class ForbiddenError(AppError):
26
+ def __init__(self, detail: str = "Permission denied.") -> None:
27
+ super().__init__(detail, status_code=403)
28
+
29
+
30
+ def register_exception_handlers(app: FastAPI) -> None:
31
+ @app.exception_handler(AppError)
32
+ async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
33
+ return JSONResponse(
34
+ status_code=exc.status_code,
35
+ content={"detail": exc.message, "type": type(exc).__name__},
36
+ )
37
+
38
+ @app.exception_handler(Exception)
39
+ async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
40
+ return JSONResponse(
41
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
42
+ content={"detail": "An unexpected error occurred.", "type": "InternalServerError"},
43
+ )
@@ -0,0 +1,70 @@
1
+ """Structured logging configuration."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ {% if log_lib == "loguru" %}
7
+ from loguru import logger
8
+
9
+
10
+ def configure_logging() -> None:
11
+ """Configure loguru as the application logger."""
12
+ logger.remove()
13
+ logger.add(
14
+ sys.stdout,
15
+ colorize=True,
16
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
17
+ level="DEBUG",
18
+ backtrace=True,
19
+ diagnose=True,
20
+ )
21
+
22
+ # Intercept standard library logging
23
+ class InterceptHandler(logging.Handler):
24
+ def emit(self, record: logging.LogRecord) -> None:
25
+ try:
26
+ level = logger.level(record.levelname).name
27
+ except ValueError:
28
+ level = record.levelno # type: ignore[assignment]
29
+ frame, depth = sys._getframe(6), 6
30
+ while frame and frame.f_code.co_filename == logging.__file__:
31
+ frame = frame.f_back # type: ignore[assignment]
32
+ depth += 1
33
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
34
+
35
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
36
+
37
+ {% elif log_lib == "structlog" %}
38
+ import structlog
39
+
40
+
41
+ def configure_logging() -> None:
42
+ """Configure structlog as the application logger."""
43
+ structlog.configure(
44
+ processors=[
45
+ structlog.contextvars.merge_contextvars,
46
+ structlog.stdlib.filter_by_level,
47
+ structlog.processors.TimeStamper(fmt="iso"),
48
+ structlog.stdlib.add_logger_name,
49
+ structlog.stdlib.add_log_level,
50
+ structlog.stdlib.PositionalArgumentsFormatter(),
51
+ structlog.processors.StackInfoRenderer(),
52
+ structlog.processors.format_exc_info,
53
+ structlog.dev.ConsoleRenderer(),
54
+ ],
55
+ wrapper_class=structlog.stdlib.BoundLogger,
56
+ context_class=dict,
57
+ logger_factory=structlog.stdlib.LoggerFactory(),
58
+ cache_logger_on_first_use=True,
59
+ )
60
+
61
+ {% else %}
62
+ def configure_logging() -> None:
63
+ """Configure standard library logging."""
64
+ logging.basicConfig(
65
+ level=logging.INFO,
66
+ format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
67
+ datefmt="%Y-%m-%d %H:%M:%S",
68
+ stream=sys.stdout,
69
+ )
70
+ {% endif %}
@@ -0,0 +1,42 @@
1
+ """Security utilities — JWT token creation and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any
7
+
8
+ from jose import JWTError, jwt
9
+ from passlib.context import CryptContext
10
+
11
+ from app.core.config import settings
12
+
13
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
14
+
15
+
16
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
17
+ return pwd_context.verify(plain_password, hashed_password)
18
+
19
+
20
+ def get_password_hash(password: str) -> str:
21
+ return pwd_context.hash(password)
22
+
23
+
24
+ def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:
25
+ expire = datetime.now(timezone.utc) + (
26
+ expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
27
+ )
28
+ payload = {"sub": str(subject), "exp": expire, "type": "access"}
29
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
30
+
31
+
32
+ def create_refresh_token(subject: str | Any) -> str:
33
+ expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
34
+ payload = {"sub": str(subject), "exp": expire, "type": "refresh"}
35
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
36
+
37
+
38
+ def decode_token(token: str) -> dict[str, Any]:
39
+ try:
40
+ return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
41
+ except JWTError as exc:
42
+ raise ValueError("Invalid or expired token.") from exc
@@ -0,0 +1,73 @@
1
+ """AWS S3 storage utilities for {{ project_name }}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import boto3
6
+ from botocore.exceptions import ClientError
7
+
8
+ from app.core.config import settings
9
+
10
+
11
+ def _get_client():
12
+ """Return a configured boto3 S3 client."""
13
+ kwargs = {
14
+ "region_name": settings.AWS_REGION,
15
+ "aws_access_key_id": settings.AWS_ACCESS_KEY_ID,
16
+ "aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY,
17
+ }
18
+ if settings.AWS_S3_ENDPOINT_URL:
19
+ kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL # MinIO / LocalStack
20
+ return boto3.client("s3", **kwargs)
21
+
22
+
23
+ def upload_file(file_path: str, object_key: str, bucket: str | None = None) -> str:
24
+ """
25
+ Upload a local file to S3.
26
+ Returns the public object key on success.
27
+ """
28
+ bucket = bucket or settings.AWS_S3_BUCKET
29
+ client = _get_client()
30
+ client.upload_file(file_path, bucket, object_key)
31
+ return object_key
32
+
33
+
34
+ def upload_fileobj(file_obj, object_key: str, bucket: str | None = None, content_type: str = "application/octet-stream") -> str:
35
+ """Upload a file-like object to S3."""
36
+ bucket = bucket or settings.AWS_S3_BUCKET
37
+ client = _get_client()
38
+ client.upload_fileobj(
39
+ file_obj,
40
+ bucket,
41
+ object_key,
42
+ ExtraArgs={"ContentType": content_type},
43
+ )
44
+ return object_key
45
+
46
+
47
+ def generate_presigned_url(object_key: str, expires_in: int = 3600, bucket: str | None = None) -> str:
48
+ """Generate a pre-signed URL for temporary read access."""
49
+ bucket = bucket or settings.AWS_S3_BUCKET
50
+ client = _get_client()
51
+ return client.generate_presigned_url(
52
+ "get_object",
53
+ Params={"Bucket": bucket, "Key": object_key},
54
+ ExpiresIn=expires_in,
55
+ )
56
+
57
+
58
+ def delete_file(object_key: str, bucket: str | None = None) -> None:
59
+ """Delete an object from S3."""
60
+ bucket = bucket or settings.AWS_S3_BUCKET
61
+ client = _get_client()
62
+ client.delete_object(Bucket=bucket, Key=object_key)
63
+
64
+
65
+ def file_exists(object_key: str, bucket: str | None = None) -> bool:
66
+ """Check if an object exists in S3."""
67
+ bucket = bucket or settings.AWS_S3_BUCKET
68
+ client = _get_client()
69
+ try:
70
+ client.head_object(Bucket=bucket, Key=object_key)
71
+ return True
72
+ except ClientError:
73
+ return False
@@ -0,0 +1,84 @@
1
+ """Database session factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ {% if has_relational_db and orm == "sqlalchemy" %}
6
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
7
+ from sqlalchemy.orm import DeclarativeBase
8
+
9
+ from app.core.config import settings
10
+
11
+ engine = create_async_engine(
12
+ settings.database_url,
13
+ echo=settings.DEBUG,
14
+ pool_pre_ping=True,
15
+ pool_size=10,
16
+ max_overflow=20,
17
+ )
18
+
19
+ AsyncSessionLocal = async_sessionmaker(
20
+ bind=engine,
21
+ class_=AsyncSession,
22
+ expire_on_commit=False,
23
+ autoflush=False,
24
+ autocommit=False,
25
+ )
26
+
27
+
28
+ class Base(DeclarativeBase):
29
+ """Base class for all SQLAlchemy ORM models."""
30
+ pass
31
+
32
+
33
+ async def get_session() -> AsyncSession: # type: ignore[return]
34
+ """FastAPI dependency that yields an async DB session."""
35
+ async with AsyncSessionLocal() as session:
36
+ try:
37
+ yield session
38
+ await session.commit()
39
+ except Exception:
40
+ await session.rollback()
41
+ raise
42
+ finally:
43
+ await session.close()
44
+
45
+ {% elif has_relational_db and orm == "tortoise" %}
46
+ from tortoise import Tortoise
47
+
48
+ from app.core.config import settings
49
+
50
+ TORTOISE_ORM = {
51
+ "connections": {"default": settings.database_url},
52
+ "apps": {
53
+ "models": {
54
+ "models": ["app.models", "aerich.models"],
55
+ "default_connection": "default",
56
+ },
57
+ },
58
+ }
59
+
60
+
61
+ async def init_db() -> None:
62
+ await Tortoise.init(config=TORTOISE_ORM)
63
+ await Tortoise.generate_schemas()
64
+
65
+
66
+ async def close_db() -> None:
67
+ await Tortoise.close_connections()
68
+
69
+ {% elif has_mongo and orm == "beanie" %}
70
+ import motor.motor_asyncio
71
+ from beanie import init_beanie
72
+
73
+ from app.core.config import settings
74
+
75
+
76
+ async def init_db() -> None:
77
+ client = motor.motor_asyncio.AsyncIOMotorClient(settings.mongodb_url)
78
+ database = client[settings.MONGODB_DB]
79
+ # TODO: Add your Beanie Document models to the list below
80
+ await init_beanie(database=database, document_models=[])
81
+
82
+ {% else %}
83
+ # No database configured. Add your connection logic here.
84
+ {% endif %}
@@ -0,0 +1,71 @@
1
+ """FastAPI application entrypoint for {{ project_name }}."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from typing import AsyncGenerator
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import ORJSONResponse
9
+
10
+ from app.api.v1 import health
11
+ {% if has_auth %}
12
+ from app.api.v1 import auth
13
+ {% endif %}
14
+ from app.core.config import settings
15
+ from app.core.exceptions import register_exception_handlers
16
+ from app.core.logging import configure_logging
17
+
18
+
19
+ @asynccontextmanager
20
+ async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
21
+ """Manage application startup and shutdown lifecycle."""
22
+ configure_logging()
23
+ {% if has_relational_db and orm == "sqlalchemy" %}
24
+ from app.db.session import engine, Base
25
+ async with engine.begin() as conn:
26
+ await conn.run_sync(Base.metadata.create_all)
27
+ {% endif %}
28
+ {% if has_mongo and orm == "beanie" %}
29
+ from app.db.session import init_db
30
+ await init_db()
31
+ {% endif %}
32
+ yield
33
+ {% if has_relational_db and orm == "sqlalchemy" %}
34
+ await engine.dispose()
35
+ {% endif %}
36
+
37
+
38
+ def create_application() -> FastAPI:
39
+ application = FastAPI(
40
+ title=settings.APP_NAME,
41
+ description="Generated by fastapi-spawn 🚀",
42
+ version="0.1.0",
43
+ docs_url="/docs",
44
+ redoc_url="/redoc",
45
+ openapi_url="/openapi.json",
46
+ default_response_class=ORJSONResponse,
47
+ lifespan=lifespan,
48
+ )
49
+
50
+ # Middleware
51
+ application.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=settings.CORS_ORIGINS,
54
+ allow_credentials=True,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+
59
+ # Exception handlers
60
+ register_exception_handlers(application)
61
+
62
+ # Routers
63
+ application.include_router(health.router, prefix=settings.API_V1_PREFIX, tags=["Health"])
64
+ {% if has_auth %}
65
+ application.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["Auth"])
66
+ {% endif %}
67
+
68
+ return application
69
+
70
+
71
+ app = create_application()