Compare commits

...

10 commits

28 changed files with 289 additions and 66 deletions

View file

@ -1,9 +1,21 @@
name: Python lint and test name: Python lint and test
on: [push, pull_request] on:
push:
branches:
- main
- 'releases/**'
paths:
- '**.py'
pull_request:
branches:
- main
- 'releases/**'
paths:
- '**.py'
jobs: jobs:
lint: linttest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -18,5 +30,7 @@ jobs:
run: black --check fooder run: black --check fooder
- name: Run flake8 - name: Run flake8
run: flake8 fooder run: flake8 fooder
- name: Run mypy
run: mypy fooder
- name: Run tests - name: Run tests
run: ./test.sh run: ./test.sh

View file

@ -14,15 +14,15 @@ push:
docker push registry.domandoman.xyz/fooder/api docker push registry.domandoman.xyz/fooder/api
black: black:
black fooder python -m black fooder
.PHONY: mypy .PHONY: mypy
mypy: mypy:
mypy fooder python -m mypy fooder
.PHONY: flake .PHONY: flake
flake: flake:
flake8 fooder python -m flake8 fooder
.PHONY: lint .PHONY: lint
lint: black mypy flake lint: black mypy flake

View file

@ -1,9 +1,6 @@
# FOODER # FOODER
Simple API for food diary application. It uses FastAPI and async postgres for faster operation. Simple API for food diary application. It uses FastAPI and async postgres.
I plan on developing a few clients for the API, for now only one is available:
- [Fooder CLI Client](https://github.com/ickyicky/fooder-cli-client)
## Usage ## Usage

View file

@ -11,3 +11,5 @@ REFRESH_SECRET_KEY="${REFRESH_SECRET_KEY}" # generate with $ openssl rand -hex 3
ALGORITHM="HS256" ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30 REFRESH_TOKEN_EXPIRE_DAYS=30
API_KEY="${API_KEY}" # generate with $ openssl rand -hex 32

View file

@ -1,11 +1,10 @@
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import async_sessionmaker
from jose import JWTError, jwt from jose import JWTError, jwt
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi_users.password import PasswordHelper from fastapi_users.password import PasswordHelper
from typing import AsyncGenerator, Annotated from typing import Annotated
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .settings import Settings from .settings import Settings
from .domain.user import User from .domain.user import User
@ -16,7 +15,7 @@ from .db import get_session
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
settings = Settings() settings = Settings()
password_helper = PasswordHelper(pwd_context) password_helper = PasswordHelper(pwd_context) # type: ignore
AsyncSessionDependency = Annotated[async_sessionmaker, Depends(get_session)] AsyncSessionDependency = Annotated[async_sessionmaker, Depends(get_session)]
TokenDependency = Annotated[str, Depends(oauth2_scheme)] TokenDependency = Annotated[str, Depends(oauth2_scheme)]
@ -32,35 +31,57 @@ def get_password_hash(password: str) -> str:
async def authenticate_user( async def authenticate_user(
session: AsyncSession, username: str, password: str session: AsyncSession, username: str, password: str
) -> AsyncGenerator[User, None]: ) -> User | None:
user = await User.get_by_username(session, username) user = await User.get_by_username(session, username)
if not user:
if user is None:
return None return None
assert user is not None
if not verify_password(password, user.hashed_password): if not verify_password(password, user.hashed_password):
return None return None
return user return user
async def verify_refresh_token( async def verify_refresh_token(
session: AsyncSession, token: str session: AsyncSession, token: str
) -> AsyncGenerator[RefreshToken, None]: ) -> RefreshToken | None:
try: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.REFRESH_SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.REFRESH_SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
username: str = payload.get("sub") sub = payload.get("sub")
if sub is None:
return None
if not isinstance(sub, str):
return None
username: str = str(sub)
if username is None: if username is None:
return return None
except JWTError: except JWTError:
return return None
user = await User.get_by_username(session, username) user = await User.get_by_username(session, username)
if user is None: if user is None:
return return None
assert user is not None
current_token = await RefreshToken.get_token(session, user.id, token) current_token = await RefreshToken.get_token(session, user.id, token)
if current_token is not None: if current_token is not None:
return current_token return current_token
return None
def create_access_token(user: User) -> str: def create_access_token(user: User) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
@ -86,18 +107,38 @@ async def create_refresh_token(session: AsyncSession, user: User) -> RefreshToke
return await RefreshToken.create(session, token=encoded_jwt, user_id=user.id) return await RefreshToken.create(session, token=encoded_jwt, user_id=user.id)
async def get_current_user( async def get_current_user(ssn: AsyncSessionDependency, token: TokenDependency) -> User:
session: AsyncSessionDependency, token: TokenDependency async with ssn() as session:
) -> User:
async with session() as session:
try: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
username: str = payload.get("sub") sub = payload.get("sub")
if sub is None:
raise HTTPException(status_code=401, detail="Unathorized")
if not isinstance(sub, str):
raise HTTPException(status_code=401, detail="Unathorized")
username: str = str(sub)
if username is None: if username is None:
raise HTTPException(status_code=401, detail="Unathorized") raise HTTPException(status_code=401, detail="Unathorized")
except JWTError: except JWTError:
raise HTTPException(status_code=401, detail="Unathorized") raise HTTPException(status_code=401, detail="Unathorized")
return await User.get_by_username(session, username) user = await User.get_by_username(session, username)
if user is None:
raise HTTPException(status_code=401, detail="Unathorized")
assert user is not None
return user
async def authorize_api_key(token: TokenDependency) -> None:
if token == settings.API_KEY:
return None
raise HTTPException(status_code=401, detail="Unathorized")

View file

@ -2,12 +2,13 @@ from typing import Annotated, Any
from fastapi import Depends from fastapi import Depends
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from ..db import get_session from ..db import get_session
from ..auth import get_current_user from ..auth import get_current_user, authorize_api_key
from ..domain.user import User from ..domain.user import User
AsyncSession = Annotated[async_sessionmaker, Depends(get_session)] AsyncSession = Annotated[async_sessionmaker, Depends(get_session)]
UserDependency = Annotated[User, Depends(get_current_user)] UserDependency = Annotated[User, Depends(get_current_user)]
ApiKeyDependency = Annotated[None, Depends(authorize_api_key)]
class BaseController: class BaseController:
@ -25,3 +26,8 @@ class AuthorizedController(BaseController):
def __init__(self, session: AsyncSession, user: UserDependency) -> None: def __init__(self, session: AsyncSession, user: UserDependency) -> None:
super().__init__(session) super().__init__(session)
self.user = user self.user = user
class TasksSessionController(BaseController):
def __init__(self, session: AsyncSession, api_key: ApiKeyDependency) -> None:
super().__init__(session)

View file

@ -39,7 +39,7 @@ class UpdateEntry(AuthorizedController):
class DeleteEntry(AuthorizedController): class DeleteEntry(AuthorizedController):
async def call(self, entry_id: int) -> Entry: async def call(self, entry_id: int) -> None:
async with self.async_session.begin() as session: async with self.async_session.begin() as session:
entry = await DBEntry.get_by_id(session, self.user.id, entry_id) entry = await DBEntry.get_by_id(session, self.user.id, entry_id)
if entry is None: if entry is None:

View file

@ -29,7 +29,7 @@ class CreateMeal(AuthorizedController):
class SaveMeal(AuthorizedController): class SaveMeal(AuthorizedController):
async def call(self, meal_id: id, payload: SaveMealPayload) -> Preset: async def call(self, meal_id: int, payload: SaveMealPayload) -> Preset:
async with self.async_session.begin() as session: async with self.async_session.begin() as session:
meal = await DBMeal.get_by_id(session, self.user.id, meal_id) meal = await DBMeal.get_by_id(session, self.user.id, meal_id)
if meal is None: if meal is None:
@ -38,7 +38,10 @@ class SaveMeal(AuthorizedController):
try: try:
return Preset.from_orm( return Preset.from_orm(
await DBPreset.create( await DBPreset.create(
session, user_id=self.user.id, name=payload.name, meal=meal session,
user_id=self.user.id,
name=payload.name or meal.name,
meal=meal,
) )
) )
except AssertionError as e: except AssertionError as e:
@ -46,7 +49,7 @@ class SaveMeal(AuthorizedController):
class DeleteMeal(AuthorizedController): class DeleteMeal(AuthorizedController):
async def call(self, meal_id: id) -> None: async def call(self, meal_id: int) -> None:
async with self.async_session.begin() as session: async with self.async_session.begin() as session:
meal = await DBMeal.get_by_id(session, self.user.id, meal_id) meal = await DBMeal.get_by_id(session, self.user.id, meal_id)
if meal is None: if meal is None:

View file

@ -32,7 +32,7 @@ class DeletePreset(AuthorizedController):
async def call( async def call(
self, self,
id: int, id: int,
) -> AsyncIterator[Preset]: ) -> None:
async with self.async_session.begin() as session: async with self.async_session.begin() as session:
preset = await DBPreset.get(session, self.user.id, id) preset = await DBPreset.get(session, self.user.id, id)

View file

@ -0,0 +1,13 @@
from fastapi import HTTPException
from ..domain.product import Product as DBProduct
from .base import TasksSessionController
class CacheProductUsageData(TasksSessionController):
async def call(self) -> None:
async with self.async_session.begin() as session:
try:
await DBProduct.cache_usage_data(session)
await session.commit()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View file

@ -41,6 +41,11 @@ class RefreshToken(BaseController):
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
user = await DBUser.get(session, current_token.user_id) user = await DBUser.get(session, current_token.user_id)
if user is None:
raise HTTPException(status_code=401, detail="Invalid token")
assert user is not None
await current_token.delete(session) await current_token.delete(session)
refresh_token = await create_refresh_token(session, user) refresh_token = await create_refresh_token(session, user)

View file

@ -17,6 +17,6 @@ class CommonMixin:
:rtype: str :rtype: str
""" """
return cls.__name__.lower() return cls.__name__.lower() # type: ignore
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)

View file

@ -3,7 +3,7 @@ from sqlalchemy import ForeignKey, Integer, Date
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from datetime import date import datetime
from typing import Optional from typing import Optional
from .base import Base, CommonMixin from .base import Base, CommonMixin
@ -17,7 +17,7 @@ class Diary(Base, CommonMixin):
meals: Mapped[list[Meal]] = relationship( meals: Mapped[list[Meal]] = relationship(
lazy="selectin", order_by=Meal.order.desc() lazy="selectin", order_by=Meal.order.desc()
) )
date: Mapped[date] = mapped_column(Date) date: Mapped[datetime.date] = mapped_column(Date)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id")) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"))
@property @property
@ -74,14 +74,16 @@ class Diary(Base, CommonMixin):
@classmethod @classmethod
async def get_diary( async def get_diary(
cls, session: AsyncSession, user_id: int, date: date cls, session: AsyncSession, user_id: int, date: datetime.date
) -> "Optional[Diary]": ) -> "Optional[Diary]":
"""get_diary.""" """get_diary."""
query = cls.query(user_id).where(cls.date == date) query = cls.query(user_id).where(cls.date == date)
return await session.scalar(query) return await session.scalar(query)
@classmethod @classmethod
async def create(cls, session: AsyncSession, user_id: int, date: date) -> "Diary": async def create(
cls, session: AsyncSession, user_id: int, date: datetime.date
) -> "Diary":
diary = Diary( diary = Diary(
date=date, date=date,
user_id=user_id, user_id=user_id,
@ -93,12 +95,13 @@ class Diary(Base, CommonMixin):
except Exception: except Exception:
raise RuntimeError() raise RuntimeError()
diary = await cls.get_by_id(session, user_id, diary.id) db_diary = await cls.get_by_id(session, user_id, diary.id)
if not diary: if not db_diary:
raise RuntimeError() raise RuntimeError()
await Meal.create(session, diary.id)
return diary await Meal.create(session, db_diary.id)
return db_diary
@classmethod @classmethod
async def get_by_id( async def get_by_id(

View file

@ -1,8 +1,8 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship, joinedload from sqlalchemy.orm import Mapped, mapped_column, relationship, joinedload
from sqlalchemy import ForeignKey, Integer, DateTime from sqlalchemy import ForeignKey, Integer, DateTime, Boolean
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import select from sqlalchemy import select, update
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -20,6 +20,7 @@ class Entry(Base, CommonMixin):
last_changed: Mapped[datetime] = mapped_column( last_changed: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
) )
processed: Mapped[bool] = mapped_column(Boolean, default=False)
@property @property
def amount(self) -> float: def amount(self) -> float:
@ -87,10 +88,10 @@ class Entry(Base, CommonMixin):
except IntegrityError: except IntegrityError:
raise AssertionError("meal or product does not exist") raise AssertionError("meal or product does not exist")
entry = await cls._get_by_id(session, entry.id) db_entry = await cls._get_by_id(session, entry.id)
if not entry: if not db_entry:
raise RuntimeError() raise RuntimeError()
return entry return db_entry
async def update( async def update(
self, self,
@ -152,3 +153,12 @@ class Entry(Base, CommonMixin):
"""delete.""" """delete."""
await session.delete(self) await session.delete(self)
await session.flush() await session.flush()
@classmethod
async def mark_processed(
cls,
session: AsyncSession,
) -> None:
stmt = update(cls).where(cls.processed == False).values(processed=True)
await session.execute(stmt)

View file

@ -84,10 +84,10 @@ class Meal(Base, CommonMixin):
except IntegrityError: except IntegrityError:
raise AssertionError("diary does not exist") raise AssertionError("diary does not exist")
meal = await cls._get_by_id(session, meal.id) db_meal = await cls._get_by_id(session, meal.id)
if not meal: if not db_meal:
raise RuntimeError() raise RuntimeError()
return meal return db_meal
@classmethod @classmethod
async def create_from_preset( async def create_from_preset(
@ -118,10 +118,10 @@ class Meal(Base, CommonMixin):
for entry in preset.entries: for entry in preset.entries:
await Entry.create(session, meal.id, entry.product_id, entry.grams) await Entry.create(session, meal.id, entry.product_id, entry.grams)
meal = await cls._get_by_id(session, meal.id) db_meal = await cls._get_by_id(session, meal.id)
if not meal: if not db_meal:
raise RuntimeError() raise RuntimeError()
return meal return db_meal
@classmethod @classmethod
async def _get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]": async def _get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]":

View file

@ -63,7 +63,7 @@ class Preset(Base, CommonMixin):
@classmethod @classmethod
async def create( async def create(
cls, session: AsyncSession, user_id: int, name: str, meal: "Meal" cls, session: AsyncSession, user_id: int, name: str, meal: "Meal"
) -> None: ) -> "Preset":
preset = Preset(user_id=user_id, name=name) preset = Preset(user_id=user_id, name=name)
session.add(preset) session.add(preset)
@ -76,7 +76,12 @@ class Preset(Base, CommonMixin):
for entry in meal.entries: for entry in meal.entries:
await PresetEntry.create(session, preset.id, entry) await PresetEntry.create(session, preset.id, entry)
return await cls.get(session, user_id, preset.id) db_preset = await cls.get(session, user_id, preset.id)
if not db_preset:
raise RuntimeError()
return db_preset
@classmethod @classmethod
async def list_all( async def list_all(

View file

@ -1,5 +1,5 @@
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import select from sqlalchemy import select, BigInteger, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import AsyncIterator, Optional from typing import AsyncIterator, Optional
@ -15,8 +15,14 @@ class Product(Base, CommonMixin):
carb: Mapped[float] carb: Mapped[float]
fat: Mapped[float] fat: Mapped[float]
fiber: Mapped[float] fiber: Mapped[float]
hard_coded_calories: Mapped[Optional[float]] = None hard_coded_calories: Mapped[Optional[float]]
barcode: Mapped[Optional[str]] = None barcode: Mapped[Optional[str]]
usage_count_cached: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
)
@property @property
def calories(self) -> float: def calories(self) -> float:
@ -41,11 +47,13 @@ class Product(Base, CommonMixin):
if q: if q:
q_list = q.split() q_list = q.split()
for qq in q_list: qq = "%" + "%".join(q_list) + "%"
query = query.filter(cls.name.ilike(f"%{qq.lower()}%")) query = query.filter(cls.name.ilike(f"%{qq.lower()}%"))
query = query.offset(offset).limit(limit) query = query.offset(offset).limit(limit)
stream = await session.stream_scalars(query.order_by(cls.id)) stream = await session.stream_scalars(
query.order_by(cls.usage_count_cached.desc())
)
async for row in stream: async for row in stream:
yield row yield row
@ -104,3 +112,28 @@ class Product(Base, CommonMixin):
session.add(product) session.add(product)
await session.flush() await session.flush()
return product return product
@classmethod
async def cache_usage_data(
cls,
session: AsyncSession,
) -> None:
from .entry import Entry
stmt = (
update(cls)
.where(
cls.id.in_(
select(Entry.product_id).where(Entry.processed == False).distinct()
)
)
.values(
usage_count_cached=select(func.count(Entry.id)).where(
Entry.product_id == cls.id,
Entry.processed == False,
)
)
)
await session.execute(stmt)
await Entry.mark_processed(session)

View file

@ -47,18 +47,18 @@ class RefreshToken(Base, CommonMixin):
:type token: str :type token: str
:rtype: "RefreshToken" :rtype: "RefreshToken"
""" """
token = cls( db_token = cls(
user_id=user_id, user_id=user_id,
token=token, token=token,
) )
session.add(token) session.add(db_token)
try: try:
await session.flush() await session.flush()
except Exception: except Exception:
raise AssertionError("invalid token") raise AssertionError("invalid token")
return token return db_token
async def delete(self, session: AsyncSession) -> None: async def delete(self, session: AsyncSession) -> None:
"""delete. """delete.

View file

@ -15,3 +15,5 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 30
ALLOWED_ORIGINS: List[str] = ["*"] ALLOWED_ORIGINS: List[str] = ["*"]
API_KEY: str

17
fooder/tasks_app.py Normal file
View file

@ -0,0 +1,17 @@
from fastapi import FastAPI
from .view.tasks import router
from .settings import Settings
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Fooder Tasks admininstrative API")
app.include_router(router)
app.add_middleware(
CORSMiddleware,
allow_origins=Settings().ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

View file

@ -1,7 +1,9 @@
from fooder.app import app from fooder.app import app
from fooder.tasks_app import app as tasks_app
from httpx import AsyncClient from httpx import AsyncClient
import pytest import pytest
import httpx import httpx
import os
class Client: class Client:
@ -67,11 +69,29 @@ class Client:
return await self.client.patch(path, **kwargs) return await self.client.patch(path, **kwargs)
class TasksClient(Client):
def __init__(self, authorized: bool = True):
super().__init__()
self.client = AsyncClient(app=tasks_app, base_url="http://testserver/api")
self.client.headers["Accept"] = "application/json"
if authorized:
self.client.headers["Authorization"] = "Bearer " + self.get_token()
def get_token(self) -> str:
return os.getenv("API_KEY")
@pytest.fixture @pytest.fixture
def unauthorized_client() -> Client: def unauthorized_client() -> Client:
return Client() return Client()
@pytest.fixture
def tasks_client() -> Client:
return TasksClient()
@pytest.fixture @pytest.fixture
async def client(user_payload) -> Client: async def client(user_payload) -> Client:
client = Client() client = Client()

15
fooder/test/test_tasks.py Normal file
View file

@ -0,0 +1,15 @@
import pytest
@pytest.mark.anyio
async def test_cache_product_usage(client, tasks_client):
response = await client.get("product")
assert response.status_code == 200, response.json()
old_data = response.json()
response = await tasks_client.post("/cache_product_usage_data")
assert response.status_code == 200, response.json()
response = await client.get("product")
assert response.status_code == 200, response.json()
assert response.json() != old_data

View file

@ -48,6 +48,5 @@ def find(bar_code: str) -> Product:
fiber=data["product"]["nutriments"].get("fiber_100g", 0.0), fiber=data["product"]["nutriments"].get("fiber_100g", 0.0),
) )
except Exception as e: except Exception as e:
raise e
logger.error(e) logger.error(e)
raise ParseError() raise ParseError()

13
fooder/view/tasks.py Normal file
View file

@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends, Request
from ..controller.tasks import CacheProductUsageData
router = APIRouter(prefix="/api", tags=["tasks"])
@router.post("/cache_product_usage_data")
async def create_user(
request: Request,
contoller: CacheProductUsageData = Depends(CacheProductUsageData),
):
return await contoller.call()

15
mypy.ini Normal file
View file

@ -0,0 +1,15 @@
[mypy]
plugins = sqlalchemy.ext.mypy.plugin,pydantic.mypy
exclude = .*/test/.*
pretty = True
platform = linux
warn_unused_configs = True
warn_unused_ignores = True
[mypy-fooder.controller.*]
disable_error_code=override

View file

@ -13,3 +13,7 @@ flake8
flake8-bugbear flake8-bugbear
httpx httpx
aiosqlite aiosqlite
mypy
types-requests
types-passlib
types-python-jose

View file

@ -1,6 +1,6 @@
[flake8] [flake8]
max-line-length = 80 max-line-length = 80
extend-select = B950 extend-select = B950
extend-ignore = E203,E501,E701 extend-ignore = E203,E501,E701,E712
extend-immutable-calls = extend-immutable-calls =
Depends Depends

View file

@ -13,6 +13,7 @@ export DB_URI="sqlite+aiosqlite:///test.db"
export ECHO_SQL=0 export ECHO_SQL=0
export SECRET_KEY=$(openssl rand -hex 32) export SECRET_KEY=$(openssl rand -hex 32)
export REFRESH_SECRET_KEY=$(openssl rand -hex 32) export REFRESH_SECRET_KEY=$(openssl rand -hex 32)
export API_KEY=$(openssl rand -hex 32)
python -m fooder --create-tables python -m fooder --create-tables
@ -23,12 +24,17 @@ else
python -m pytest fooder --disable-warnings -sv python -m pytest fooder --disable-warnings -sv
fi fi
status=$?
# unset test env values # unset test env values
unset POSTGRES_USER unset POSTGRES_USER
unset POSTGRES_DATABASE unset POSTGRES_DATABASE
unset POSTGRES_PASSWORD unset POSTGRES_PASSWORD
unset SECRET_KEY unset SECRET_KEY
unset REFRESH_SECRET unset REFRESH_SECRET
unset API_KEY
# if exists, remove test.db # if exists, remove test.db
[ -f test.db ] && rm test.db [ -f test.db ] && rm test.db
exit $status