Skip to content

Commit d14530b

Browse files
authored
Merge pull request #27 from getmarkus/cm-branch-25
refactor: streamline session management and remove unused code
2 parents 157facc + 9fe2dbf commit d14530b

File tree

20 files changed

+312
-294
lines changed

20 files changed

+312
-294
lines changed

.env.example

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Application Settings
2+
APP_PROJECT_NAME=python-template
3+
# sqlite:// sqlite:///./test.db
4+
APP_DATABASE_URL=postgresql+psycopg://app_user:change_this_password@localhost:5432/app_database
5+
APP_DATABASE_SCHEMA=issue_analysis
6+
APP_MIGRATE_DATABASE=false
7+
APP_SQLITE_WAL_MODE=false
8+
# collection, sqlmodel
9+
APP_DATABASE_TYPE=sqlmodel
10+
11+
# PostgreSQL Configuration (for reference)
12+
POSTGRES_SUPER_USER=app_user
13+
POSTGRES_SUPER_PASSWORD=change_this_password
14+
POSTGRES_DB=app_database
15+
POSTGRES_HOST=localhost
16+
POSTGRES_PORT=5432
17+
18+
# CORS Settings
19+
APP_BACKEND_CORS_ORIGINS=["http://localhost:8000","http://localhost:3000"]
20+
APP_CORS_ALLOW_CREDENTIALS=false
21+
APP_CORS_ALLOW_METHODS=["*"]
22+
APP_CORS_ALLOW_HEADERS=["X-Forwarded-For","Authorization","Content-Type","X-Request-ID"]
23+
APP_CORS_EXPOSE_HEADERS=["X-Process-Time","X-Request-ID"]

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ celerybeat.pid
121121

122122
# Environments
123123
.env
124+
.env.testing
124125
.venv
125126
env/
126127
venv/
@@ -204,4 +205,5 @@ Temporary Items
204205
*.sqlite3
205206
*.sqlite-journal
206207
*.sqlite3-journal
207-
*.db-journal
208+
*.db-journal
209+
*.sql

.vscode/settings.json

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
{
2-
"python.testing.pytestArgs": [
3-
"app"
4-
],
5-
"python.testing.unittestEnabled": false,
6-
"python.testing.pytestEnabled": true,
7-
"launch": {
8-
"configurations": [],
9-
"compounds": []
10-
}
11-
}
2+
"python.testing.pytestArgs": ["app"],
3+
"python.testing.unittestEnabled": false,
4+
"python.testing.pytestEnabled": true
5+
}

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ Simple python template I am experimenting with around a set of overlapping conce
1111
uv run fastapi dev main.py
1212
uv run pytest
1313

14-
uvx dynaconf list
15-
1614
atlas schema inspect -u "sqlite://issues.db" --format "{{ sql . }}" > migrate.sql
15+
16+
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory" --dry-run
17+
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory"
18+
19+
atlas schema apply --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" --to "file://schema.sql" --dev-url "docker://postgres/17"
1720
```
1821

1922
```mermaid

app/core/factory.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,20 @@
11
from contextlib import asynccontextmanager
22

33
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
45

56
from app.interface_adapters import api_router
6-
from app.interface_adapters.containers import Container
77
from app.interface_adapters.exceptions import AppException
88
from app.interface_adapters.middleware.error_handler import app_exception_handler
99
from app.resource_adapters.persistence.sqlmodel.database import get_engine
10-
from config import settings
10+
from config import Settings
1111

1212

13-
def create_container():
14-
container = Container()
15-
container.wire(packages=["app"])
16-
return container
17-
18-
19-
def create_app(lifespan_handler=None) -> FastAPI:
13+
def create_app(settings: Settings, lifespan_handler=None) -> FastAPI:
2014
if lifespan_handler is None:
2115

2216
@asynccontextmanager
2317
async def default_lifespan(app: FastAPI):
24-
# Initialize container and wire dependencies
25-
container = create_container()
2618

2719
# Initialize database if using SQLModel
2820
get_engine()
@@ -32,7 +24,6 @@ async def default_lifespan(app: FastAPI):
3224
yield
3325

3426
app.state.running = False
35-
container.unwire()
3627

3728
lifespan_handler = default_lifespan
3829

@@ -48,4 +39,18 @@ async def default_lifespan(app: FastAPI):
4839
# Register routes
4940
app.include_router(api_router, prefix="/v1")
5041

42+
# Configure CORS
43+
app.add_middleware(
44+
CORSMiddleware,
45+
allow_origins=(
46+
[str(origin) for origin in settings.backend_cors_origins]
47+
if settings.backend_cors_origins
48+
else ["*"]
49+
),
50+
allow_credentials=settings.cors_allow_credentials,
51+
allow_methods=settings.cors_allow_methods,
52+
allow_headers=settings.cors_allow_headers,
53+
expose_headers=settings.cors_expose_headers,
54+
)
55+
5156
return app

app/domain/aggregate_root.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
from sqlmodel import SQLModel
66

7-
from config import settings
8-
97

108
class BaseCommand(Protocol):
119
command_id: str
@@ -21,11 +19,11 @@ class BaseEvent(Protocol):
2119

2220

2321
class AggregateRoot(SQLModel, abc.ABC):
24-
# This sets the database schema in which to create tables for all subclasses of BaseModel
25-
# __tablename__ = "sometable"
26-
__table_args__ = {"schema": settings.database_schema}
2722
version: int = 0
2823

24+
def __init__(self, **data):
25+
super().__init__(**data)
26+
2927
# or handle()
3028
@abc.abstractmethod
3129
def process(self, command: BaseCommand) -> list[BaseEvent]:

app/interface_adapters/containers.py

Lines changed: 0 additions & 31 deletions
This file was deleted.

app/interface_adapters/endpoints/issues.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
from typing import Annotated
22

3-
from dependency_injector.wiring import inject
43
from fastapi import APIRouter, Depends
54
from loguru import logger
65

76
from app.core.ports.repositories.issues import IssueRepository
87
from app.core.usecases.analyze_issue import AnalyzeIssue
98
from app.domain.issue import Issue
10-
from app.interface_adapters.containers import Container
9+
from app.resource_adapters.persistence.sqlmodel.database import SessionDep
10+
from app.resource_adapters.persistence.sqlmodel.issues import SQLModelIssueRepository
1111

1212
router = APIRouter()
1313

1414

15-
def get_repository() -> IssueRepository:
16-
return Container.issue_repository()
15+
def get_repository(session: SessionDep) -> IssueRepository:
16+
with SQLModelIssueRepository(session) as repo:
17+
yield repo
1718

1819

1920
@router.post("/issues/{issue_number}/analyze", response_model=Issue)
20-
@inject
2121
def analyze_issue(
2222
issue_number: int,
2323
repo: Annotated[IssueRepository, Depends(get_repository)],

app/resource_adapters/persistence/sqlmodel/database.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,41 @@
1-
from typing import Generator
1+
from typing import Annotated, Generator
22

3+
from fastapi import Depends
34
from loguru import logger
4-
from sqlalchemy import inspect, text
5+
from sqlalchemy import inspect, schema, text
56
from sqlalchemy.engine import Engine
67
from sqlalchemy.pool import StaticPool
78
from sqlmodel import Session, SQLModel, create_engine
89

9-
from config import settings
10+
from config import Settings, get_settings
1011

1112
_engine: Engine | None = None
1213

1314

14-
def get_db() -> Generator[Session, None, None]:
15+
def get_session(
16+
settings: Annotated[Settings, Depends(get_settings)],
17+
) -> Generator[Session, Settings, None]:
1518
with Session(_engine) as session:
19+
session.connection(
20+
execution_options={"schema_translate_map": {None: settings.database_schema}}
21+
)
1622
yield session
1723

1824

19-
def get_engine(database_url: str | None = None) -> Engine:
25+
SessionDep = Annotated[Session, Depends(get_session)]
26+
27+
28+
def get_engine(_settings: Settings | None = None) -> Engine:
2029
"""Get or create SQLModel engine instance."""
2130
global _engine
2231

2332
if _engine is not None:
2433
return _engine
2534

26-
if database_url is None:
27-
database_url = settings.database_url
35+
if _settings is None:
36+
_settings = get_settings()
37+
38+
database_url = _settings.database_url
2839

2940
# Configure engine based on database type
3041
engine_args = {"echo": True}
@@ -38,29 +49,38 @@ def get_engine(database_url: str | None = None) -> Engine:
3849
_engine = create_engine(database_url, **engine_args)
3950

4051
# Enable WAL mode if configured
41-
if settings.sqlite_wal_mode:
52+
if _settings.sqlite_wal_mode:
4253
with _engine.connect() as conn:
4354
# https://www.sqlite.org/pragma.html
4455
conn.execute(text("PRAGMA journal_mode=WAL"))
4556
# conn.execute(text("PRAGMA synchronous=OFF"))
4657
logger.info("SQLite WAL mode enabled")
4758

4859
# Initialize database if using SQLModel
49-
if settings.model_config == "sqlmodel" and not settings.migrate_database:
60+
if (
61+
_settings.database_type == "sqlmodel"
62+
and _settings.database_schema
63+
and not _settings.database_url.startswith("sqlite")
64+
):
65+
SQLModel.metadata.schema = _settings.database_schema
66+
with _engine.connect() as conn:
67+
conn.execution_options = {
68+
"schema_translate_map": {None: _settings.database_schema}
69+
}
70+
if not conn.dialect.has_schema(conn, _settings.database_schema):
71+
logger.warning(
72+
f"Schema '{_settings.database_schema}' not found in database. Creating..."
73+
)
74+
conn.execute(schema.CreateSchema(_settings.database_schema))
75+
conn.commit()
76+
77+
if _settings.database_type == "sqlmodel" and not _settings.migrate_database:
5078
logger.info("Creating database tables...")
5179

52-
if settings.database_schema:
53-
SQLModel.metadata.schema = settings.database_schema
54-
# with self.engine.connect() as conn:
55-
# if not conn.dialect.has_schema(conn, db_schema):
56-
# logger.warning(f"Schema '{db_schema}' not found in database. Creating...")
57-
# conn.execute(sa.schema.CreateSchema(db_schema))
58-
# conn.commit()
59-
6080
# Check if tables exist before creating them
6181
inspector = inspect(_engine)
6282
existing_tables = inspector.get_table_names(
63-
schema=settings.database_schema if settings.database_schema else None
83+
schema=_settings.database_schema if _settings.database_schema else None
6484
)
6585
if not existing_tables:
6686
# if settings.database_schema:

app/tests/conftest.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
import os
22
from contextlib import asynccontextmanager
3+
from pathlib import Path
34

45
import pytest
56
from _pytest.config import Config
7+
from dotenv import load_dotenv
68
from fastapi import FastAPI
79
from fastapi.testclient import TestClient
10+
from loguru import logger
811
from sqlmodel import Session, delete
912

1013
from app.core.factory import create_app
1114
from app.resource_adapters.persistence.sqlmodel.database import get_engine
1215
from app.resource_adapters.persistence.sqlmodel.issues import Issue
13-
from config import settings
16+
from config import Settings
1417

18+
# Specify the custom .env file
19+
dotenv_path = Path(".env.testing")
20+
load_dotenv(dotenv_path=dotenv_path, override=True)
1521

16-
@pytest.fixture(scope="session", autouse=True)
17-
def set_test_settings():
18-
"""Fixture to force the use of the 'testing' environment for all tests."""
19-
settings.configure(FORCE_ENV_FOR_DYNACONF="testing")
22+
23+
settings = Settings()
2024

2125

2226
def pytest_unconfigure(config: Config) -> None:
2327
"""Clean up after each test."""
2428
# Remove test database if it exists
2529
if os.path.exists("test.db"):
30+
logger.info("Removing test database")
2631
os.remove("test.db")
2732

2833

@@ -37,13 +42,13 @@ async def test_lifespan(app: FastAPI):
3742
@pytest.fixture(name="app")
3843
def test_app():
3944
"""Create test app instance only during test execution."""
40-
return create_app(lifespan_handler=test_lifespan)
45+
return create_app(settings, lifespan_handler=test_lifespan)
4146

4247

4348
@pytest.fixture(name="session")
4449
def test_session():
4550
"""Session fixture for testing environment using test database."""
46-
with Session(get_engine()) as session:
51+
with Session(get_engine(settings)) as session:
4752
yield session
4853
statement = delete(Issue)
4954
session.exec(statement)

0 commit comments

Comments
 (0)