Last week we got pytest to run asynchronous test methods. That was the preparation step for this post where we switch to asynchronous SQLAlchemy for our to-do application. As it turns out, switching to asynchronous methods for SQLAlchemy takes a lot of work. Let us get through the different changes we need to make.
Install the asynchronous SQLite driver
The default driver for SQLite only works with synchronous commands. To access SQLite with the asynchronous engine, we need to install this package:
We start our change by turning our tests for the data store into asynchronous methods. As we learned last week, this consists of these 4 main steps:
Add the async keyword in front of our test methods.
Add the async decorator to our test methods.
Add an await in front of all method calls to our data store.
Add the async fixture decorator to our fixture.
In our fixture we call the function create_async_session_factory() to get the asynchronous session factory. While we could work with the session in our data store in the synchronous world, we cannot do the same with the asynchronous engine. If we try, we get an endless list of errors about closed transactions and other problems. Therefore, we will put the factory inside our data store.
fromtypingimportAsyncIteratorfromsqlalchemy.ext.asyncioimportAsyncSessionfromsqlalchemyimportfunc,selectfromdatetimeimportdate,datetimefrom..models.statisticsimportStatisticOverviewfrom..models.todoimportTaskInput,TaskOutputfrom.entitiesimportTaskclassDataStoreDb:def__init__(self,db:AsyncIterator[AsyncSession]):self.db=dbasyncdefadd(self,entry:TaskInput)->TaskOutput:asyncwithself.db()assession:task=Task(id=None,created_at=datetime.now(),**dict(entry))session.add(task)awaitsession.commit()returnself.__to_output(task)asyncdefget(self,id:int)->TaskOutput:asyncwithself.db()assession:query=select(Task).where(Task.id==id)result=awaitsession.scalar(query)ifresult:returnself.__to_output(result)else:returnNoneasyncdefall(self):asyncwithself.db()assession:query=select(Task)entries=awaitsession.scalars(query)results=[]forentryinentries:results.append(self.__to_output(entry))returnresultsasyncdefdelete(self,id:int)->None:asyncwithself.db()assession:query=select(Task).where(Task.id==id)entry=awaitsession.scalar(query)ifentry:awaitsession.delete(entry)awaitsession.commit()asyncdefupdate(self,id:int,update:TaskInput)->TaskOutput:asyncwithself.db()assession:query=select(Task).where(Task.id==id)entry=awaitsession.scalar(query)ifentry:entry.name=update.nameentry.priority=update.priorityentry.due_date=update.due_dateentry.done=update.doneawaitsession.commit()returnself.__to_output(entry)else:raiseValueError(f"no taks known with id '{id}'")asyncdefget_statistics(self)->StatisticOverview:asyncwithself.db()assession:query=(select(func.count("*").label("total"),func.count("*").filter(Task.done==True).label("done"),func.count("*").filter(Task.done==False).label("open"),))result_db=awaitsession.execute(query)#https://stackoverflow.com/questions/36515882/command-cursor-object-is-not-subscriptableresult=list(result_db)[0]returnStatisticOverview(total_tasks=result[0],total_done=result[1],total_open=result[2])def__to_output(self,entity:Task)->TaskOutput:returnTaskOutput(id=entity.id,name=entity.name,priority=entity.priority,due_date=entity.due_date,done=entity.done,created_at=date.today())
We can now run our tests for the datastore, and they all should pass. However, the tests for FastAPI application will fail. Let us fix that.
Fix the endpoint tests
The tests for our FastAPI endpoints need an adjustment for the override_get_db() function. All the methods need the await keyword and the marker, and we need to await the call to the prepare_task() function:
fromdatetimeimportdate,timedeltaimportosimportrefrombs4importBeautifulSoupfromfastapi.testclientimportTestClientimportpytestfrom..dependenciesimportget_dbfrom..data.datastore_dbimportDataStoreDbfrom..data.databaseimportcreate_async_session_factoryfrom..mainimportappimportlogginglogging.getLogger("httpx").setLevel(logging.WARNING)asyncdefoverride_get_db():db_file=os.path.join(os.path.dirname(__file__),'..','db','test_db.sqlite')factory=awaitcreate_async_session_factory(db_file)db=DataStoreDb(factory)yielddbclient=TestClient(app)app.dependency_overrides[get_db]=override_get_db@pytest.mark.asyncioasyncdeftest_create_task():data={"name":"A first task","priority":5,"due_date":str(date.today()+timedelta(days=1)),"done":False}response=client.post("/api/todo/",json=data)assertresponse.status_code==201result=response.json()assertresult['id']>0assertresult['done']==Falseassertresult['created_at']==str(date.today())assertresult['name']==data['name']assertresult['priority']==data['priority']assertresult['due_date']==data['due_date']assertf"http://testserver/api/todo/{result['id']}"==response.headers['location']@pytest.mark.asyncioasyncdefprepare_task(name,priority=4,due_date=None,done=False):ifdue_date==None:due_date=date.today()+timedelta(days=1)data={"name":name,"priority":priority,"due_date":str(due_date),"done":done}prepare_response=client.post("/api/todo/",json=data)assertprepare_response.status_code==201returnprepare_response.json()['id']@pytest.mark.asyncioasyncdeftest_show_task():name="A second task"id=awaitprepare_task(name)response=client.get(f"/api/todo/{id}")assertresponse.status_code==200details=response.json()assertdetails['name']==name@pytest.mark.asyncioasyncdeftest_show_task_where_task_is_unknown():response=client.get(f"/api/todo/-1")assertresponse.status_code==404assertresponse.json()['detail']=="Task not found"@pytest.mark.asyncioasyncdeftest_update_task():id=awaitprepare_task("original")update={"name":"An updated task","priority":5,"due_date":str(date.today()+timedelta(days=2)),"done":False}response=client.put(f"/api/todo/{id}",json=update)assertresponse.status_code==200assertresponse.json()['name']=="An updated task"check=client.get(f"/api/todo/{id}")assertcheck.json()['name']=="An updated task"@pytest.mark.asyncioasyncdeftest_delete_task():id=awaitprepare_task("to delete")response=client.delete(f"/api/todo/{id}")assertresponse.status_code==204check=client.get(f"/api/todo/{id}")assertcheck.status_code==404@pytest.mark.asyncioasyncdeftest_main_page_shows_info_message():response=client.get("/")assertresponse.status_code==200assertresponse.json()['message']=="The minimalistic ToDo API"@pytest.mark.asyncioasyncdeftest_show_all_tasks():awaitprepare_task("a first task")awaitprepare_task("a second task")awaitprepare_task("a third task")response=client.get("/api/todo")assertresponse.status_code==200tasks=response.json()assertlen(tasks)>=3@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_are_not_done():awaitprepare_task("a finished task",done=True)awaitprepare_task("an open task",done=False)response=client.get("/api/todo?include_done=false")assertresponse.status_code==200tasks=response.json()done=[taskfortaskintasksiftask['done']==True]assertlen(done)==0@pytest.mark.asyncioasyncdeftest_show_all_tasks_that_are_due_within_five_days():awaitprepare_task("in 10 days",due_date=date.today()+timedelta(days=10))response=client.get(f"/api/todo?due_before={date.today()+timedelta(days=5)}")assertresponse.status_code==200tasks=response.json()done=[taskfortaskintasksifdate.fromisoformat(task['due_date'])>date.today()+timedelta(days=5)]assertlen(done)==0@pytest.mark.asyncioasyncdeftest_docs_endpoint_works():response=client.get("/openapi.json")# No exception -> test passes@pytest.mark.asyncioasyncdeftest_about_page():response=client.get("/about")assertresponse.status_code==200soup=BeautifulSoup(response.text,'html.parser')assertsoup.title.text=="About To-Do Task API"assertsoup.body.h1.text=="About"@pytest.mark.asyncioasyncdeftest_dashboard():response=client.get("/dashboard")assertresponse.status_code==200soup=BeautifulSoup(response.text,'html.parser')assertsoup.title.text=="Dashboard To-Do Task API"assertsoup.body.h1.text=="Dashboard"numbers=re.findall(r"\d+",soup.body.p.text)assertint(numbers[0])==int(numbers[1])+int(numbers[2])
Fix the dependencies.py code
We need to switch to the create_async_session_factory() function and change how we initialise our data store:
importosfrom.data.databaseimportcreate_async_session_factoryfrom.data.datastore_dbimportDataStoreDbasyncdefget_db():""" Creates the datastore """db_file=os.path.join(os.path.dirname(__file__),'.','db','todo_api.sqlite')factory=awaitcreate_async_session_factory(db_file)db=DataStoreDb(factory)yielddb
Fix the FastAPI endpoints
In all our endpoints we need to await the call to the db.* methods, the rest of the files can stay the same.
Our main.py file has one place to await the call to the db.get_statistics() method:
fromfastapiimportDepends,FastAPI,Requestfromfastapi.encodersimportjsonable_encoderfromfastapi.responsesimportHTMLResponsefromfastapi.staticfilesimportStaticFilesfromfastapi.templatingimportJinja2Templatesfrom.data.datastore_dbimportDataStoreDbfrom.dependenciesimportget_dbfrom.routersimporttodofrompathlibimportPathBASE_DIR=Path(__file__).resolve().parentapp=FastAPI()app.include_router(todo.router,prefix="/api/todo")app.mount("/static",StaticFiles(directory=str(Path(BASE_DIR,'static'))),name="static")templates=Jinja2Templates(directory=str(Path(BASE_DIR,'templates')))@app.get("/about",response_class=HTMLResponse)asyncdefabout(request:Request):returntemplates.TemplateResponse(request=request,name="about.html")@app.get("/",include_in_schema=False)asyncdefmain():return{'message':'The minimalistic ToDo API'}@app.get("/dashboard",include_in_schema=False)asyncdefdashboard(request:Request,db:DataStoreDb=Depends(get_db)):stats=awaitdb.get_statistics()returntemplates.TemplateResponse(request=request,name="dashboard.html",context=jsonable_encoder(stats))
In the routers/todo.py file, we need an await in every method:
fromdatetimeimportdate,timedeltafromtypingimportAnnotated,ListfromfastapiimportAPIRouter,Depends,HTTPException,Request,Response,statusfromfastapi.encodersimportjsonable_encoderfromfastapi.responsesimportJSONResponsefrom..dependenciesimportget_dbfrom..models.todoimportTaskOutput,TaskInputfrom..data.datastore_dbimportDataStoreDbrouter=APIRouter()asyncdeffilter_parameters(q:str|None=None,include_done:bool=True,due_before:date=date.today()+timedelta(days=365)):return{"q":q,"include_done":include_done,"due_before":due_before}@router.get("/")asyncdefshow_all_tasks(filter:Annotated[dict,Depends(filter_parameters)],db:DataStoreDb=Depends(get_db))->List[TaskOutput]:result=awaitdb.all()ifnotfilter["include_done"]:result=[itemforiteminresultifitem.done==False]result=[itemforiteminresultifitem.due_date<=filter["due_before"]]returnresult@router.post("/",status_code=status.HTTP_201_CREATED)asyncdefcreate_task(task:TaskInput,request:Request,db:DataStoreDb=Depends(get_db))->TaskOutput:result=awaitdb.add(task)headers={"Location":f"{request.base_url}api/todo/{result.id}"}returnJSONResponse(content=jsonable_encoder(result),status_code=status.HTTP_201_CREATED,headers=headers)@router.get("/{id}")asyncdefshow_task(id:int,db:DataStoreDb=Depends(get_db))->TaskOutput:result=awaitdb.get(id)ifresult:returnresultelse:raiseHTTPException(status_code=404,detail="Task not found")@router.put("/{id}")asyncdefupdate_task(id:int,task:TaskInput,db:DataStoreDb=Depends(get_db))->TaskOutput:try:result=awaitdb.update(id,task)returnresultexceptValueError:raiseHTTPException(status_code=404,detail="Task not found")@router.delete("/{id}",status_code=status.HTTP_204_NO_CONTENT)asyncdefdelete_task(id:int,db:DataStoreDb=Depends(get_db))->None:awaitdb.delete(id)returnResponse(status_code=status.HTTP_204_NO_CONTENT)
We can now run all tests and they should pass. Without those tests we would need to spend an awful amount of time to run the application by hand. Therefore, make sure that you have a good test coverage for your code before you start such a massive change.
Next
With all these changes we can now run our to-do application with asynchronous SQLAlchemy. As you can see in this post, even for a small application that is a massive change. It took me a few rounds to get everything back into a working state and I would not do it without a reliable test suite.