Create FastAPI REST API with Python and SQLite

- Implemented user authentication with JWT
- Added CRUD operations for users and items
- Setup database connection with SQLAlchemy
- Added migration scripts for easy database setup
- Included health check endpoint for monitoring

generated with BackendIM... (backend.im)
This commit is contained in:
Automated Action 2025-05-13 18:33:54 +00:00
parent dd43e41b24
commit f2d3f2d55c
22 changed files with 895 additions and 2 deletions

View File

@ -1,3 +1,89 @@
# FastAPI Application
# Generic REST API Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A generic REST API service built with FastAPI and SQLite, providing endpoints for user management and item operations.
## Features
- User management (registration, authentication, profile management)
- JWT-based authentication
- Item CRUD operations
- Database migrations with Alembic
- Comprehensive API documentation
- Health check endpoint
## Project Structure
```
├── app/
│ ├── database/ # Database connection and session management
│ ├── models/ # SQLAlchemy ORM models
│ ├── routes/ # API route definitions
│ ├── schemas/ # Pydantic schemas for request/response validation
│ ├── utils/ # Utility functions
│ └── storage/ # Storage for database and files
├── migrations/ # Alembic migration scripts
├── alembic.ini # Alembic configuration
├── main.py # Application entry point
└── requirements.txt # Project dependencies
```
## Setup
1. Clone the repository
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
uvicorn main:app --reload
```
## API Documentation
Once the application is running, you can access the automatically generated API documentation at:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## Available Endpoints
### Authentication
- `POST /api/v1/users/token` - Login and get access token
### Users
- `POST /api/v1/users/` - Register a new user
- `GET /api/v1/users/me` - Get current user profile
- `PUT /api/v1/users/me` - Update current user profile
- `GET /api/v1/users/` - List all users (requires authentication)
- `GET /api/v1/users/{user_id}` - Get user by ID (requires authentication)
### Items
- `POST /api/v1/items/` - Create a new item (requires authentication)
- `GET /api/v1/items/` - List all items (requires authentication)
- `GET /api/v1/items/my-items` - List all items owned by current user (requires authentication)
- `GET /api/v1/items/{item_id}` - Get item by ID (requires authentication)
- `PUT /api/v1/items/{item_id}` - Update item (requires ownership)
- `DELETE /api/v1/items/{item_id}` - Delete item (requires ownership)
### Health Check
- `GET /health` - API health status
## Database Migrations
To apply migrations:
```bash
alembic upgrade head
```
## License
MIT

84
alembic.ini Normal file
View File

@ -0,0 +1,84 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
app/database/__init__.py Normal file
View File

@ -0,0 +1 @@
from .database import Base, engine, SessionLocal, get_db

25
app/database/database.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
# Define database directory
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

2
app/models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .user import User
from .item import Item

19
app/models/item.py Normal file
View File

@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database.database import Base
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(Text)
price = Column(Float)
is_active = Column(Boolean, default=True)
owner_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
owner = relationship("User")

15
app/models/user.py Normal file
View File

@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.database.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

0
app/routes/__init__.py Normal file
View File

28
app/routes/health.py Normal file
View File

@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from datetime import datetime
from app.database import get_db
router = APIRouter()
@router.get("/health", tags=["health"])
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint to verify API status
"""
# Test database connection
try:
# Just execute a simple query
db.execute("SELECT 1")
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
return {
"status": "ok",
"timestamp": datetime.now().isoformat(),
"database": db_status,
"version": "0.1.0"
}

124
app/routes/items.py Normal file
View File

@ -0,0 +1,124 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.models import Item, User
from app.schemas import ItemCreate, ItemUpdate, ItemResponse
from app.utils.security import get_current_active_user
router = APIRouter(prefix="/items", tags=["items"])
@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(
item: ItemCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
db_item = Item(
**item.dict(),
owner_id=current_user.id,
)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[ItemResponse])
def read_items(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
items = db.query(Item).filter(Item.is_active == True).offset(skip).limit(limit).all()
return items
@router.get("/my-items", response_model=List[ItemResponse])
def read_my_items(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
items = (
db.query(Item)
.filter(Item.owner_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return items
@router.get("/{item_id}", response_model=ItemResponse)
def read_item(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
db_item = db.query(Item).filter(Item.id == item_id).first()
if db_item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
return db_item
@router.put("/{item_id}", response_model=ItemResponse)
def update_item(
item_id: int,
item: ItemUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
db_item = db.query(Item).filter(Item.id == item_id).first()
if db_item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
# Check ownership
if db_item.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Update item attributes
for key, value in item.dict(exclude_unset=True).items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
db_item = db.query(Item).filter(Item.id == item_id).first()
if db_item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
# Check ownership
if db_item.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
db.delete(db_item)
db.commit()
return None

143
app/routes/users.py Normal file
View File

@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import List
from datetime import timedelta
from app.database import get_db
from app.models import User
from app.schemas import UserCreate, UserUpdate, UserResponse, Token
from app.utils.security import (
get_password_hash,
authenticate_user,
create_access_token,
get_current_active_user,
ACCESS_TOKEN_EXPIRE_MINUTES,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
# Check if user with this email already exists
db_user_email = db.query(User).filter(User.email == user.email).first()
if db_user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if user with this username already exists
db_user_username = db.query(User).filter(User.username == user.username).first()
if db_user_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken",
)
# Create new user
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
username=user.username,
hashed_password=hashed_password,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.get("/me", response_model=UserResponse)
def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@router.put("/me", response_model=UserResponse)
def update_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
# Update email if provided and not already in use
if user_update.email is not None and user_update.email != current_user.email:
db_user = db.query(User).filter(User.email == user_update.email).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
current_user.email = user_update.email
# Update username if provided and not already in use
if user_update.username is not None and user_update.username != current_user.username:
db_user = db.query(User).filter(User.username == user_update.username).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken",
)
current_user.username = user_update.username
# Update password if provided
if user_update.password is not None:
current_user.hashed_password = get_password_hash(user_update.password)
# Update is_active if provided
if user_update.is_active is not None:
current_user.is_active = user_update.is_active
db.commit()
db.refresh(current_user)
return current_user
@router.post("/token", response_model=Token)
def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/", response_model=List[UserResponse])
def read_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def read_user(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
db_user = db.query(User).filter(User.id == user_id).first()
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return db_user

3
app/schemas/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .user import UserCreate, UserUpdate, UserResponse, UserInDB
from .item import ItemCreate, ItemUpdate, ItemResponse
from .token import Token, TokenData

31
app/schemas/item.py Normal file
View File

@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ItemBase(BaseModel):
title: str
description: Optional[str] = None
price: float
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
is_active: Optional[bool] = None
class ItemResponse(ItemBase):
id: int
is_active: bool
owner_id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
orm_mode = True

11
app/schemas/token.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None

40
app/schemas/user.py Normal file
View File

@ -0,0 +1,40 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
username: str
class UserCreate(UserBase):
password: str = Field(..., min_length=8)
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
class UserInDB(UserBase):
id: int
is_active: bool
hashed_password: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
orm_mode = True
class UserResponse(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
orm_mode = True

0
app/utils/__init__.py Normal file
View File

77
app/utils/security.py Normal file
View File

@ -0,0 +1,77 @@
from datetime import datetime, timedelta
from typing import Union, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import TokenData
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/users/token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def authenticate_user(db: Session, username: str, password: str):
user = db.query(User).filter(User.username == username).first()
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == token_data.username).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

32
main.py Normal file
View File

@ -0,0 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routes import items, users, health
from app.database import engine, Base
# Create tables
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Generic REST API Service",
description="A generic REST API service built with FastAPI and SQLite",
version="0.1.0",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(users.router, prefix="/api/v1", tags=["users"])
app.include_router(items.router, prefix="/api/v1", tags=["items"])
app.include_router(health.router, tags=["health"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

79
migrations/env.py Normal file
View File

@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.database import Base
from app.models import User, Item # import all models here
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,60 @@
"""Initial migration
Revision ID: 1a2b3c4d5e6f
Revises:
Create Date: 2023-09-12 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1a2b3c4d5e6f'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create items table
op.create_table('items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False)
op.create_index(op.f('ix_items_title'), 'items', ['title'], unique=False)
def downgrade():
op.drop_index(op.f('ix_items_title'), table_name='items')
op.drop_index(op.f('ix_items_id'), table_name='items')
op.drop_table('items')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.103.1
uvicorn==0.23.2
sqlalchemy==2.0.20
pydantic==2.3.0
alembic==1.12.0
python-jose==3.3.0
passlib==1.7.4
python-multipart==0.0.6
bcrypt==4.0.1