Skip to content

Commit 2ad80d5

Browse files
authored
Merge pull request #46 from getmarkus/codex_mob
async operations
2 parents 180c8fd + 106ba2e commit 2ad80d5

File tree

12 files changed

+597
-273
lines changed

12 files changed

+597
-273
lines changed

.env.example

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,15 @@ APP_PROJECT_NAME=python-template
55
# sqlite:///:memory: follows normal exception handling rules
66
# APP_DATABASE_URL_TEMPLATE=sqlite:///:memory:
77
# APP_DATABASE_URL_TEMPLATE=sqlite:///./issues.db
8-
APP_DATABASE_URL_TEMPLATE=postgresql+psycopg://app_user:change_this_password@localhost:5432/app_database
8+
# APP_DATABASE_URL_TEMPLATE=postgresql+psycopg://app_user:change_this_password@localhost:5432/app_database
9+
# Or, for an async driver using asyncpg:
10+
APP_DATABASE_URL_TEMPLATE=postgresql+asyncpg://app_user:change_this_password@localhost:5432/app_database
911
APP_DATABASE_SCHEMA=issue_analysis
10-
APP_MIGRATE_DATABASE=false
12+
APP_CREATE_TABLES=true
1113
APP_SQLITE_WAL_MODE=false
12-
# collection, sqlmodel
13-
APP_DATABASE_TYPE=sqlmodel
1414

15-
# PostgreSQL Configuration (for reference)
16-
POSTGRES_SUPER_USER=app_user
17-
POSTGRES_SUPER_PASSWORD=change_this_password
18-
POSTGRES_DB=app_database
19-
POSTGRES_HOST=localhost
20-
POSTGRES_PORT=5432
15+
# APP_CURRENT_ENV can be 'default' or 'testing'
16+
APP_CURRENT_ENV=default
2117

2218
# CORS Settings
2319
APP_BACKEND_CORS_ORIGINS=["http://localhost:8000","http://localhost:3000"]

AGENTS.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Design and Principles
2+
3+
- Simple python template I am experimenting with around a set of overlapping concepts with a Fastapi implementation, primarily:
4+
- Vertical Slice
5+
- With elements of:
6+
- Domain Driven Design (DDD)
7+
- Clean Architecture
8+
- Ports & Adapters
9+
- CQRS
10+
11+
## Pair Programming Rules
12+
13+
- do not make git commits
14+
- pair program with the human developer with <https://github.com/remotemobprogramming/mob> framework
15+
- human developer will the only one who executes `mob done`
16+
- agent will execute `mob start` when coding starts
17+
- agent will execute `mob next` when coding is complete and ready for review
18+
19+
## Core System Components
20+
21+
- The main application logic is in app.
22+
- Feature flags and configuration settings are in config.py.
23+
24+
## Code styles
25+
26+
- Use PEP 8 and pythonic code
27+
- Use type hints
28+
- Functions: Use `snake_case`
29+
- Variables: Use `snake_case`
30+
- Constants: Use `UPPER_CASE`
31+
- Classes: Use `CamelCase`
32+
- Use `uv` for dependency management
33+
- Use `pyenv` for python version management
34+
- Use `loguru` for logging
35+
- Use `pydantic` for data validation
36+
- Use `sqlmodel` for database interactions
37+
- Use `pydantic-settings` for configuration
38+
- Use `python-dotenv` for environment variables
39+
- Use `pytest` for testing
40+
- Use `pytest-cov` for coverage
41+
42+
## Data & Storage
43+
44+
- Support for Sqlite and Postgres via sqlmodel.
45+
- Use `atlas` for database migrations
46+
- Use `migrations` directory for migration files
47+
- Use Bytebase and Supabase style guides
48+
- Identifier Style: snake_case, lowercase, descriptive
49+
- Table Names: snake_case, plural, no prefixes, descriptive
50+
- Column Names: snake_case, singular, descriptive, avoid generic, with `_id`
51+
- Data Types: Prefer specific types, ENUM for small sets, avoid CHAR(n)
52+
- Query Formatting: Lowercase keywords, whitespace, clear indentation
53+
- Functions: Lowercase, snake_case
54+
- Constraints: Explicit, descriptive names
55+
56+
## Testing & Debugging
57+
58+
- Unit, component and integration tests in app/tests
59+
- Create unit and component tests first before writing the actual code
60+
- Follow code implementation with integration tests
61+
62+
## Workflow and developer interaction
63+
64+
- TBD
65+
66+
## Bash commands
67+
68+
- Running api server:
69+
70+
```bash
71+
uv run fastapi dev main.py
72+
```
73+
74+
- Running tests:
75+
76+
```bash
77+
uv run pytest
78+
```
79+
80+
- Running database and migrations:
81+
82+
```bash
83+
docker compose up
84+
docker compose down -v
85+
```
86+
87+
- Various migration commands depending on database type:
88+
89+
```bash
90+
atlas schema inspect -u "sqlite://file?cache=shared&mode=memory" --format "{{ sql . }}"
91+
atlas schema inspect -u "sqlite://issues.db" --format "{{ sql . }}" > migrate.sql
92+
atlas schema inspect -u "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --schema "issue_analysis" --format "{{ sql . }}"
93+
94+
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory" --dry-run
95+
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory"
96+
97+
atlas schema apply --url "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --to "file://./migrations/migrate.sql" --dev-url "docker://postgres/17" --dry-run
98+
atlas schema apply --url "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --to "file://./migrations/migrate.sql" --dev-url "docker://postgres/17"
99+
```

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ 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+
docker compose up
15+
docker compose down -v
16+
1417
atlas schema inspect -u "sqlite://file?cache=shared&mode=memory" --format "{{ sql . }}"
1518
atlas schema inspect -u "sqlite://issues.db" --format "{{ sql . }}" > migrate.sql
1619
atlas schema inspect -u "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --schema "issue_analysis" --format "{{ sql . }}"
@@ -21,7 +24,3 @@ atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-ur
2124
atlas schema apply --url "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --to "file://./migrations/migrate.sql" --dev-url "docker://postgres/17" --dry-run
2225
atlas schema apply --url "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --to "file://./migrations/migrate.sql" --dev-url "docker://postgres/17"
2326
```
24-
25-
```mermaid
26-
graph TD;
27-
```

app/core/database.py

Lines changed: 125 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
from typing import Annotated, Generator
1+
from typing import Annotated, Generator, AsyncGenerator
22

33
from fastapi import Depends
44
from loguru import logger
55
from sqlalchemy.engine import Engine
6+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
7+
from sqlmodel.ext.asyncio.session import AsyncSession
8+
from sqlalchemy.orm import sessionmaker
69
from sqlalchemy.pool import StaticPool
710
from sqlmodel import Session, SQLModel, create_engine, MetaData
811
from sqlalchemy import text, schema
912

1013
from config import Settings, get_settings
1114

1215
_engine: Engine | None = None
16+
_async_engine: AsyncEngine | None = None
1317
_metadata: MetaData | None = None
18+
_async_sessionmaker: sessionmaker[AsyncSession] | None = None
1419

1520

1621
def get_metadata(
@@ -62,48 +67,134 @@ def get_engine(_settings: Settings | None = None) -> Engine:
6267
if _settings is None:
6368
_settings = get_settings()
6469

65-
# Configure engine based on database type
66-
engine_args = {"echo": True}
70+
# Get the base URL template and credentials
71+
url = _settings.database_url_template
6772

68-
# Add SQLite-specific settings for in-memory database
69-
if _settings.database_url.startswith("sqlite"):
73+
try:
74+
from sqlalchemy.engine import make_url
75+
76+
# Just for validation purposes
77+
make_url(url)
78+
except ImportError:
79+
logger.warning("Could not import sqlalchemy.engine.make_url")
80+
81+
engine_args: dict = {"echo": True}
82+
if url.startswith("sqlite"):
7083
engine_args.update(
7184
{"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
7285
)
7386

74-
_engine = create_engine(_settings.database_url, **engine_args)
87+
_engine = create_engine(url, **engine_args)
7588

76-
# Enable WAL mode if configured
77-
if _settings.sqlite_wal_mode:
89+
if _settings.sqlite_wal_mode and url.startswith("sqlite"):
7890
with _engine.connect() as conn:
79-
# https://www.sqlite.org/pragma.html
8091
conn.execute(text("PRAGMA journal_mode=WAL"))
81-
# conn.execute(text("PRAGMA synchronous=OFF"))
8292
logger.info("SQLite WAL mode enabled")
8393

84-
# Initialize schema if using SQLModel, with a schema if not sqlite
85-
if _settings.database_type == "sqlmodel":
86-
SQLModel.metadata.schema = _settings.get_table_schema
87-
with _engine.connect() as conn:
88-
""" conn.execution_options = {
89-
"schema_translate_map": {None: _settings.get_table_schema}
90-
} """
91-
if not _settings.database_url.startswith(
92-
"sqlite"
93-
) and not conn.dialect.has_schema(conn, _settings.get_table_schema):
94-
logger.warning(
95-
f"Schema '{_settings.get_table_schema}' not found in database. Creating..."
96-
)
97-
conn.execute(schema.CreateSchema(_settings.get_table_schema))
98-
conn.commit()
99-
100-
if not _settings.migrate_database:
101-
SQLModel.metadata.create_all(conn)
102-
conn.commit()
103-
logger.info("Database tables created successfully")
104-
else:
105-
logger.info(
106-
"Database tables already exist or migration is configured, skipping creation"
107-
)
94+
# Set schema for SQLModel metadata
95+
SQLModel.metadata.schema = _settings.get_table_schema
96+
with _engine.connect() as conn:
97+
if not url.startswith("sqlite") and not conn.dialect.has_schema(
98+
conn,
99+
_settings.get_table_schema, # type: ignore[arg-type]
100+
):
101+
logger.warning(
102+
f"Schema '{_settings.get_table_schema}' not found in database. Creating..."
103+
)
104+
conn.execute(schema.CreateSchema(_settings.get_table_schema))
105+
conn.commit()
106+
107+
if _settings.create_tables:
108+
SQLModel.metadata.create_all(conn)
109+
conn.commit()
110+
logger.info("Database tables created successfully")
111+
else:
112+
logger.info(
113+
"Database tables already exist or migration is configured, skipping creation"
114+
)
108115

109116
return _engine
117+
118+
119+
def get_async_engine(_settings: Settings | None = None) -> AsyncEngine:
120+
"""Get or create async SQLModel engine instance."""
121+
global _async_engine, _async_sessionmaker
122+
123+
if _async_engine is not None:
124+
return _async_engine
125+
126+
if _settings is None:
127+
_settings = get_settings()
128+
129+
url = _settings.database_url
130+
engine_args: dict = {"echo": True}
131+
132+
# SQLite: use the aiosqlite driver for async support
133+
if url.startswith("sqlite") and "+aiosqlite" not in url:
134+
url = url.replace("sqlite://", "sqlite+aiosqlite://", 1)
135+
engine_args.update(
136+
{"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
137+
)
138+
else:
139+
# PostgreSQL: ensure an asyncpg driver if scheme is postgres/postgresql without a +driver suffix
140+
try:
141+
scheme, rest = url.split("://", 1)
142+
except ValueError:
143+
scheme = url
144+
rest = ""
145+
146+
# Handle SSL mode for asyncpg
147+
connect_args = {}
148+
if "?" in rest:
149+
base_url, query_string = rest.split("?", 1)
150+
params = {}
151+
for param in query_string.split("&"):
152+
if "=" in param:
153+
key, value = param.split("=", 1)
154+
params[key] = value
155+
156+
# Remove sslmode from URL and add as connect_args for asyncpg
157+
if "sslmode" in params:
158+
sslmode = params.pop("sslmode")
159+
if sslmode == "disable":
160+
connect_args["ssl"] = False
161+
elif sslmode in ("require", "verify-ca", "verify-full"):
162+
connect_args["ssl"] = True
163+
164+
# Rebuild the URL without sslmode
165+
new_query = "&".join([f"{k}={v}" for k, v in params.items()])
166+
if new_query:
167+
rest = f"{base_url}?{new_query}"
168+
else:
169+
rest = base_url
170+
171+
if connect_args:
172+
engine_args["connect_args"] = connect_args
173+
174+
if scheme in ("postgres", "postgresql") and "+" not in scheme:
175+
url = f"postgresql+asyncpg://{rest}"
176+
177+
engine_args: dict = {"echo": True}
178+
if url.startswith("sqlite"):
179+
engine_args.update(
180+
{"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
181+
)
182+
183+
_async_engine = create_async_engine(url, **engine_args)
184+
_async_sessionmaker = sessionmaker(
185+
_async_engine, class_=AsyncSession, expire_on_commit=False
186+
)
187+
return _async_engine
188+
189+
190+
async def get_async_session(
191+
settings: Annotated[Settings, Depends(get_settings)],
192+
) -> AsyncGenerator[AsyncSession, None]:
193+
# Initialize the async engine if it doesn't exist yet
194+
get_async_engine(settings)
195+
assert _async_sessionmaker is not None
196+
async with _async_sessionmaker() as session:
197+
yield session
198+
199+
200+
AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)]

app/core/factory.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from app.core.exceptions import AppException
88
from app.core.middleware import app_exception_handler
99
from config import Settings
10-
from app.core.database import get_engine
10+
from app.core.database import get_async_engine, get_engine
1111

1212
# Create API router and include feature routers
1313
api_router = APIRouter()
@@ -19,7 +19,18 @@ def create_app(settings: Settings, lifespan_handler=None) -> FastAPI:
1919

2020
@asynccontextmanager
2121
async def default_lifespan(app: FastAPI):
22-
get_engine(settings)
22+
# Choose database engine based on configuration
23+
if settings.is_async_database:
24+
# Use async engine for async database URLs
25+
get_async_engine(settings)
26+
else:
27+
# For sync database URLs, we need to initialize the engine outside the async context
28+
# This is done here for simplicity, but in production you might want to
29+
# use a background task or separate initialization step
30+
import asyncio
31+
loop = asyncio.get_event_loop()
32+
await loop.run_in_executor(None, lambda: get_engine(settings))
33+
2334
app.state.running = True
2435

2536
yield

0 commit comments

Comments
 (0)