Skip to content

Commit bcbf367

Browse files
committed
refactor: simplify settings retrieval and enhance info endpoint
1 parent 134e785 commit bcbf367

File tree

6 files changed

+97
-65
lines changed

6 files changed

+97
-65
lines changed

app/interface_adapters/endpoints/issues.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
from loguru import logger
55
from sqlmodel import Session
66

7-
from config import Settings
87
from app.core.ports.repositories.issues import IssueRepository
98
from app.core.usecases.analyze_issue import AnalyzeIssue
109
from app.domain.issue import Issue
1110
from app.interface_adapters.exceptions import UnsupportedOperationException
1211
from app.resource_adapters.persistence.in_memory.issues import InMemoryIssueRepository
1312
from app.resource_adapters.persistence.sqlmodel.database import get_db
1413
from app.resource_adapters.persistence.sqlmodel.issues import SQLModelIssueRepository
14+
from config import settings
1515

1616
router = APIRouter()
1717

@@ -20,7 +20,7 @@
2020
def configure_repository(
2121
session: Annotated[Session, Depends(get_db)]
2222
) -> IssueRepository:
23-
execution_mode = Settings.get_settings().execution_mode
23+
execution_mode = settings.execution_mode
2424
if execution_mode == "in-memory":
2525
return InMemoryIssueRepository()
2626
elif execution_mode == "sqlmodel":
@@ -34,8 +34,7 @@ def configure_repository(
3434

3535
@router.post("/issues/{issue_number}/analyze", response_model=Issue)
3636
def analyze_issue(
37-
issue_number: int,
38-
repo: Annotated[IssueRepository, Depends(configure_repository)]
37+
issue_number: int, repo: Annotated[IssueRepository, Depends(configure_repository)]
3938
) -> Issue:
4039
logger.info(f"analyzing issue: {issue_number}")
4140
use_case = AnalyzeIssue(issue_number=issue_number, repo=repo)

app/resource_adapters/persistence/sqlmodel/database.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sqlalchemy.engine import Engine
55
from sqlmodel import Session, SQLModel, create_engine
66

7-
from config import Settings
7+
from config import settings
88

99
_engine: Engine | None = None
1010

@@ -22,14 +22,11 @@ def get_engine(database_url: str | None = None) -> Engine:
2222
return _engine
2323

2424
if database_url is None:
25-
database_url = Settings.get_settings().database_url
25+
database_url = settings.database_url
2626

2727
_engine = create_engine(database_url, echo=True)
2828
# Initialize database if using SQLModel
29-
if (
30-
Settings.get_settings().execution_mode == "sqlmodel"
31-
and not Settings.get_settings().migrate_database
32-
):
29+
if settings.execution_mode == "sqlmodel" and not settings.migrate_database:
3330
logger.info("Creating database tables...")
3431
SQLModel.metadata.create_all(_engine)
3532
logger.info("Database tables created successfully")

app/tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,3 @@ def client_fixture():
2222

2323
with TestClient(app) as client:
2424
yield client
25-

config.py

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,41 @@
1-
import tomllib
21
from functools import lru_cache
32
from typing import List
43

4+
from dynaconf import Dynaconf, Validator
55
from pydantic import AnyHttpUrl
6-
from pydantic_settings import BaseSettings, SettingsConfigDict
76

7+
# order matters
8+
# [default] settings in settings.toml are loaded first
9+
# [APP_ENV] settings override defaults
10+
# .env file overrides those
11+
# Environment variables have the highest priority
12+
# CLI arguments override those
813

9-
# https://fastapi.tiangolo.com/advanced/settings/
10-
# https://docs.pydantic.dev/latest/concepts/pydantic_settings/
11-
# https://docs.pydantic.dev/latest/concepts/pydantic_settings/#command-line-support
12-
class Settings(BaseSettings):
13-
# https://stackoverflow.com/questions/70852331/how-to-define-class-attributes-after-inheriting-pydantics-basemodel
14-
# https://stackoverflow.com/questions/69388833/triggering-a-function-on-creation-of-an-pydantic-object
15-
pyproject_file: str = ""
16-
execution_mode: str = ""
17-
env_smoke_test: str = ""
18-
project_name: str = ""
19-
database_url: str = "sqlite:///./issues.db"
20-
migrate_database: bool = False
21-
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
22-
# e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
23-
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
24-
backend_cors_origins: List[AnyHttpUrl] = []
14+
settings = Dynaconf(
15+
envvar_prefix="APP",
16+
settings_files=["settings.toml", ".secrets.toml"],
17+
environments=True,
18+
load_dotenv=True,
19+
validators=[
20+
Validator("database_url", must_exist=True),
21+
Validator("project_name", must_exist=True),
22+
Validator("migrate_database", is_type_of=bool),
23+
Validator("backend_cors_origins", is_type_of=list),
24+
],
25+
)
2526

26-
model_config = SettingsConfigDict(env_file=".env", extra="allow")
27-
# `.env.prod` takes priority over `.env`
28-
# env_file=('.env', '.env.prod')
2927

30-
def setFromTOML(self):
31-
with open(self.pyproject_file, "rb") as f:
32-
_META = tomllib.load(f)
33-
self.project_name = _META["project"]["name"]
28+
# Singleton pattern to ensure only one settings instance
29+
@lru_cache()
30+
def get_settings():
31+
return settings
3432

35-
@classmethod
36-
@lru_cache
37-
def get_settings(cls):
38-
settings = Settings(pyproject_file="pyproject.toml")
39-
settings.setFromTOML()
40-
return settings
33+
34+
# Type hints for your settings (optional but recommended)
35+
class Settings:
36+
project_name: str
37+
database_url: str
38+
migrate_database: bool
39+
backend_cors_origins: List[AnyHttpUrl]
40+
execution_mode: str
41+
env_smoke_test: str

main.py

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
11
import datetime
22
import uuid
33
from contextlib import asynccontextmanager
4-
from typing import Annotated, Any, Dict, Optional
4+
from typing import Any, Dict, Optional
55

6-
from fastapi import Depends, FastAPI
6+
from fastapi import FastAPI
77
from fastapi.middleware.cors import CORSMiddleware
88
from fastapi.responses import JSONResponse
99
from loguru import logger
1010
from pydantic import BaseModel, Field
1111

12-
from config import Settings
1312
from app.interface_adapters import api_router
1413
from app.interface_adapters.exceptions import AppException
1514
from app.interface_adapters.middleware.error_handler import app_exception_handler
1615
from app.resource_adapters.persistence.sqlmodel.database import get_engine
16+
from config import settings
1717

1818
# https://brandur.org/logfmt
1919
# https://github.com/Delgan/loguru
2020
# https://betterstack.com/community/guides/logging/loguru/
2121

22+
2223
def isRunning() -> bool:
23-
#logger.info(f"App state: {dict(app.state.__dict__)}")
24+
# logger.info(f"App state: {dict(app.state.__dict__)}")
2425
running = getattr(app.state, "running", False)
2526
logger.info(f"Running state: {running}")
27+
logger.info(f"Environment: {settings.current_env}")
2628
return running
2729

2830

@@ -35,8 +37,7 @@ async def lifespan(app: FastAPI):
3537

3638
app.state.running = True
3739
logger.info("Lifespan started")
38-
logger.info(f"App state: {dict(app.state.__dict__)}")
39-
logger.info(app.state.running)
40+
isRunning()
4041

4142
yield
4243

@@ -45,8 +46,8 @@ async def lifespan(app: FastAPI):
4546

4647

4748
def is_configured() -> bool:
48-
logger.info(f"configured: {Settings.get_settings().env_smoke_test == "configured"}")
49-
return Settings.get_settings().env_smoke_test == "configured"
49+
logger.info(f"configured: {settings.env_smoke_test == 'configured'}")
50+
return settings.env_smoke_test == "configured"
5051

5152

5253
""" {
@@ -86,7 +87,7 @@ def create_health_response(is_healthy: bool, check_name: str) -> JSONResponse:
8687
status = "pass" if is_healthy else "fail"
8788
response = HealthCheck(
8889
status=status,
89-
version=Settings.get_settings().project_name,
90+
version=settings.project_name,
9091
description=f"Service {status}",
9192
checks={
9293
check_name: {
@@ -105,7 +106,7 @@ def create_health_response(is_healthy: bool, check_name: str) -> JSONResponse:
105106

106107
app = FastAPI(
107108
lifespan=lifespan,
108-
title=Settings.get_settings().project_name,
109+
title=settings.project_name, # Use settings directly
109110
openapi_url="/v1/openapi.json",
110111
)
111112

@@ -129,6 +130,7 @@ def create_health_response(is_healthy: bool, check_name: str) -> JSONResponse:
129130
# gzip compression - GZipMiddleware
130131
# ssl enforcement - HTTPSRedirectMiddleware
131132

133+
132134
@app.middleware("http")
133135
async def add_request_id(request, call_next):
134136
request_id = str(uuid.uuid4())
@@ -138,47 +140,49 @@ async def add_request_id(request, call_next):
138140
logger.info(f"Request {request_id} to {request.url.path}")
139141
return response
140142

143+
141144
@app.middleware("http")
142145
async def add_process_time_header(request, call_next):
143146
import time
147+
144148
start_time = time.perf_counter()
145149
response = await call_next(request)
146150
process_time = time.perf_counter() - start_time
147151
response.headers["X-Process-Time"] = str(process_time)
148152
logger.info(f"Request to {request.url.path} took {process_time:.4f} seconds")
149153
return response
150154

155+
151156
app.add_middleware(
152157
CORSMiddleware,
153-
allow_origins=["*"], # Set this to lock down if applicable to your use case
154-
# allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
155-
allow_credentials=False, # Must be false if all origins (*) is allowed
156-
allow_methods=["*"],
157-
allow_headers=["X-Forwarded-For", "Authorization", "Content-Type", "X-Request-ID"],
158-
expose_headers=["X-Process-Time", "X-Request-ID"]
158+
allow_origins=(
159+
[str(origin) for origin in settings.backend_cors_origins]
160+
if settings.backend_cors_origins
161+
else ["*"]
162+
),
163+
allow_credentials=settings.cors_allow_credentials,
164+
allow_methods=settings.cors_allow_methods,
165+
allow_headers=settings.cors_allow_headers,
166+
expose_headers=settings.cors_expose_headers,
159167
)
160168

161169

162170
@app.get("/")
163-
async def root(
164-
settings: Annotated[Settings, Depends(Settings.get_settings)],
165-
) -> Dict[str, Any]:
171+
async def root() -> Dict[str, Any]:
166172
return {
167173
"app_name": settings.project_name,
168174
"system_time": datetime.datetime.now(),
169175
}
170176

171177

172178
@app.get("/info")
173-
async def info(
174-
settings: Annotated[Settings, Depends(Settings.get_settings)],
175-
) -> Dict[str, Any]:
176-
logger.info(settings.model_dump())
179+
async def info() -> Dict[str, Any]:
177180
return {
178181
"app_name": settings.project_name,
179182
"system_time": datetime.datetime.now(),
180183
"execution_mode": settings.execution_mode,
181184
"env_smoke_test": settings.env_smoke_test,
185+
"env": settings.current_env,
182186
}
183187

184188

settings.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[default]
2+
project_name = "python-template"
3+
database_url = "sqlite:///./issues.db"
4+
migrate_database = false
5+
execution_mode = "sqlmodel"
6+
env_smoke_test = ""
7+
8+
# CORS Settings
9+
backend_cors_origins = []
10+
cors_allow_credentials = false
11+
cors_allow_methods = ["*"]
12+
cors_allow_headers = [
13+
"X-Forwarded-For",
14+
"Authorization",
15+
"Content-Type",
16+
"X-Request-ID",
17+
]
18+
cors_expose_headers = ["X-Process-Time", "X-Request-ID"]
19+
20+
[development]
21+
# Development-specific settings
22+
backend_cors_origins = ["http://localhost:8000", "http://localhost:3000"]
23+
24+
[production]
25+
# Production-specific settings
26+
backend_cors_origins = [] # Set specific origins in production
27+
cors_allow_methods = [
28+
"GET",
29+
"POST",
30+
"PUT",
31+
"DELETE",
32+
] # Be more restrictive in production

0 commit comments

Comments
 (0)