Skip to content

Commit 152d61a

Browse files
committed
setup alembic with async
1 parent 04981f5 commit 152d61a

16 files changed

+757
-5
lines changed

alembic.ini

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = migrations
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
# Uncomment the line below if you want the files to be prepended with date and time
9+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
10+
11+
# sys.path path, will be prepended to sys.path if present.
12+
# defaults to the current working directory.
13+
prepend_sys_path = .
14+
15+
# timezone to use when rendering the date within the migration file
16+
# as well as the filename.
17+
# If specified, requires the python-dateutil library that can be
18+
# installed by adding `alembic[tz]` to the pip requirements
19+
# string value is passed to dateutil.tz.gettz()
20+
# leave blank for localtime
21+
# timezone =
22+
23+
# max length of characters to apply to the
24+
# "slug" field
25+
# truncate_slug_length = 40
26+
27+
# set to 'true' to run the environment during
28+
# the 'revision' command, regardless of autogenerate
29+
# revision_environment = false
30+
31+
# set to 'true' to allow .pyc and .pyo files without
32+
# a source .py file to be detected as revisions in the
33+
# versions/ directory
34+
# sourceless = false
35+
36+
# version location specification; This defaults
37+
# to migrations/versions. When using multiple version
38+
# directories, initial revisions must be specified with --version-path.
39+
# The path separator used here should be the separator specified by "version_path_separator" below.
40+
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
41+
42+
# version path separator; As mentioned above, this is the character used to split
43+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
44+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
45+
# Valid values for version_path_separator are:
46+
#
47+
# version_path_separator = :
48+
# version_path_separator = ;
49+
# version_path_separator = space
50+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
51+
52+
# the output encoding used when revision files
53+
# are written from script.py.mako
54+
# output_encoding = utf-8
55+
56+
sqlalchemy.url = postgresql+asyncpg://user:pass@localhost:5432/ecommerce
57+
58+
59+
[post_write_hooks]
60+
# post_write_hooks defines scripts or Python functions that are run
61+
# on newly generated revision scripts. See the documentation for further
62+
# detail and examples
63+
64+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
65+
# hooks = black
66+
# black.type = console_scripts
67+
# black.entrypoint = black
68+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
69+
70+
# Logging configuration
71+
[loggers]
72+
keys = root,sqlalchemy,alembic
73+
74+
[handlers]
75+
keys = console
76+
77+
[formatters]
78+
keys = generic
79+
80+
[logger_root]
81+
level = WARN
82+
handlers = console
83+
qualname =
84+
85+
[logger_sqlalchemy]
86+
level = WARN
87+
handlers =
88+
qualname = sqlalchemy.engine
89+
90+
[logger_alembic]
91+
level = INFO
92+
handlers =
93+
qualname = alembic
94+
95+
[handler_console]
96+
class = StreamHandler
97+
args = (sys.stderr,)
98+
level = NOTSET
99+
formatter = generic
100+
101+
[formatter_generic]
102+
format = %(levelname)-5.5s [%(name)s] %(message)s
103+
datefmt = %H:%M:%S

authentication/crud.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import bcrypt
2+
from fastapi import Depends, status
3+
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from fastapi_utils.cbv import cbv
7+
from fastapi_utils.inferring_router import InferringRouter
8+
from starlette.responses import JSONResponse, Response
9+
from authentication.models import User, UserCreate
10+
11+
from common.concurrency import cpu_bound_task
12+
from common.database.db import get_session
13+
from common.injection import on
14+
15+
16+
auth_router = InferringRouter()
17+
18+
19+
@cbv(auth_router)
20+
class AuthCrud:
21+
22+
@auth_router.post(
23+
'/register',
24+
status_code=status.HTTP_201_CREATED
25+
)
26+
async def register(self, user_in: UserCreate, session: AsyncSession = Depends(get_session)):
27+
user = User(name=user_in.name, email=user_in.email, password=user_in.password, phone=user_in.phone)
28+
user.password = (
29+
await cpu_bound_task(
30+
bcrypt.hashpw, user.password.encode(), bcrypt.gensalt()
31+
)
32+
).decode()
33+
session.add(user)
34+
await session.commit()
35+
await session.refresh(user)
36+
return user
37+

authentication/models.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Optional
2+
3+
from sqlmodel import SQLModel, Field
4+
5+
class UserBase(SQLModel):
6+
name: str
7+
email: str
8+
password: str
9+
phone: str
10+
11+
class User(UserBase, table=True):
12+
id: int = Field(default=None, primary_key=True)
13+
14+
15+
class UserCreate(UserBase):
16+
pass

common/concurrency.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import asyncio
2+
from concurrent.futures.thread import ThreadPoolExecutor
3+
from typing import Callable
4+
5+
6+
_executor =ThreadPoolExecutor()
7+
8+
async def cpu_bound_task(func: Callable, *args):
9+
"""
10+
Execute async function in a separate thread, without blocking the main event
11+
loop.
12+
13+
:param func: async function that will be executed in separate thread
14+
:param args: async function parameters
15+
:return: async function result
16+
"""
17+
return await asyncio.get_event_loop().run_in_executor(_executor, func, *args)

common/config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ class DevSettings(Settings):
1414
prod: bool = True
1515
fastapi_log_level: str = 'debug'
1616
domain_name: str = 'http://localhost:8000'
17+
database_uri:str = ""
1718

1819

1920
class ProdSettings(Settings):
20-
prod: False
21+
prod: bool = False
2122
fastapi_log_level: str = 'info'
2223
domain_name: str = 'http://localhost:8000'
2324

common/database/db.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
3+
from common.config import cfg
4+
5+
from sqlmodel import SQLModel
6+
7+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
8+
from sqlalchemy.orm import sessionmaker
9+
10+
11+
engine = create_async_engine(cfg.database_uri, echo=True, future=True)
12+
13+
async def init_db():
14+
async with engine.begin() as conn:
15+
await conn.run_sync(SQLModel.metadata.create_all)
16+
17+
18+
async def get_session() -> AsyncSession:
19+
async_session = sessionmaker(
20+
engine, class_ = AsyncSession, expire_on_commit=False
21+
)
22+
async with async_session() as session:
23+
yield session

common/exceptions.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import Any, Dict, Optional
2+
3+
from fastapi import HTTPException
4+
5+
6+
class HTTPExceptionJSON(HTTPException):
7+
"""FastAPI HTTPException enriched with 'code' and 'data' fields, useful to
8+
add some context to the error message."""
9+
10+
def __init__(
11+
self,
12+
status_code: int,
13+
code: str = None,
14+
detail: Any = None,
15+
headers: Optional[Dict[str, Any]] = None,
16+
data: Dict[str, Any] = None,
17+
) -> None:
18+
super().__init__(status_code=status_code, detail=detail, headers=headers)
19+
self.code = code
20+
self.data = data
21+
22+
23+
class UnexpectedRelationshipState(Exception):
24+
pass

common/injection.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import Callable, Type, TypeVar
2+
from injector import Injector, singleton
3+
4+
injector = Injector()
5+
T = TypeVar('T')
6+
7+
def on(dependency_class: Type[T]) -> Callable[[], T]:
8+
"""Bridge between FastAPI injection and 'injector' DI framework."""
9+
return lambda: injector.get(dependency_class)

docker-compose.yml

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
services:
2+
pgdatabase:
3+
image: postgres:13
4+
environment:
5+
- POSTGRES_USER=user
6+
- POSTGRES_PASSWORD=pass
7+
- POSTGRES_DB=ecommerce
8+
ports:
9+
- '5432:5432'
10+
volumes:
11+
- db:/var/lib/postgresql/data
12+
pgadmin:
13+
image: dpage/pgadmin4
14+
environment:
15+
16+
- PGADMIN_DEFAULT_PASSWORD=admin
17+
ports:
18+
- '8080:80'
19+
20+
volumes:
21+
db:
22+
driver: local

main.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import uvicorn
22
from fastapi import APIRouter, FastAPI
33

4+
from authentication.crud import auth_router
45

56
# Init FastAPI app
67
app = FastAPI(
@@ -13,12 +14,14 @@
1314
# Routers
1415
api_router = APIRouter(prefix='/api')
1516

17+
api_router.include_router(auth_router, tags=['auth'])
18+
1619
app.include_router(api_router)
1720

1821
# Startup event
19-
@app.on_event('startup')
20-
async def startup():
21-
...
22+
# @app.on_event('startup')
23+
# async def startup():
24+
# ...
2225

2326
# Shutdown event
2427
async def shutdown():

migrations/README

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Generic single-database configuration with an async dbapi.

migrations/env.py

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import asyncio
2+
from logging.config import fileConfig
3+
4+
from sqlalchemy import engine_from_config
5+
from sqlalchemy import pool
6+
from sqlalchemy.engine import Connection
7+
from sqlalchemy.ext.asyncio import AsyncEngine
8+
from sqlmodel import SQLModel
9+
10+
from alembic import context
11+
12+
from authentication.models import User
13+
14+
# this is the Alembic Config object, which provides
15+
# access to the values within the .ini file in use.
16+
config = context.config
17+
18+
# Interpret the config file for Python logging.
19+
# This line sets up loggers basically.
20+
if config.config_file_name is not None:
21+
fileConfig(config.config_file_name)
22+
23+
# add your model's MetaData object here
24+
# for 'autogenerate' support
25+
# from myapp import mymodel
26+
# target_metadata = mymodel.Base.metadata
27+
target_metadata = SQLModel.metadata
28+
29+
# other values from the config, defined by the needs of env.py,
30+
# can be acquired:
31+
# my_important_option = config.get_main_option("my_important_option")
32+
# ... etc.
33+
34+
35+
def run_migrations_offline() -> None:
36+
"""Run migrations in 'offline' mode.
37+
38+
This configures the context with just a URL
39+
and not an Engine, though an Engine is acceptable
40+
here as well. By skipping the Engine creation
41+
we don't even need a DBAPI to be available.
42+
43+
Calls to context.execute() here emit the given string to the
44+
script output.
45+
46+
"""
47+
url = config.get_main_option("sqlalchemy.url")
48+
context.configure(
49+
url=url,
50+
target_metadata=target_metadata,
51+
literal_binds=True,
52+
dialect_opts={"paramstyle": "named"},
53+
)
54+
55+
with context.begin_transaction():
56+
context.run_migrations()
57+
58+
59+
def do_run_migrations(connection: Connection) -> None:
60+
context.configure(connection=connection, target_metadata=target_metadata)
61+
62+
with context.begin_transaction():
63+
context.run_migrations()
64+
65+
66+
async def run_migrations_online() -> None:
67+
"""Run migrations in 'online' mode.
68+
69+
In this scenario we need to create an Engine
70+
and associate a connection with the context.
71+
72+
"""
73+
connectable = AsyncEngine(
74+
engine_from_config(
75+
config.get_section(config.config_ini_section),
76+
prefix="sqlalchemy.",
77+
poolclass=pool.NullPool,
78+
future=True,
79+
)
80+
)
81+
82+
async with connectable.connect() as connection:
83+
await connection.run_sync(do_run_migrations)
84+
85+
await connectable.dispose()
86+
87+
88+
if context.is_offline_mode():
89+
run_migrations_offline()
90+
else:
91+
asyncio.run(run_migrations_online())

0 commit comments

Comments
 (0)