Skip to content
This repository has been archived by the owner on Sep 12, 2023. It is now read-only.

Commit

Permalink
fix(reloading): use uvicorn.run()
Browse files Browse the repository at this point in the history
Logic for reloading is in `uvicorn.run()`, but to use it we need to let
uvicorn manage the event loop for us.

So this commit moves the database/cache readiness checks into a
starlite, "before_startup" hook handler, so that they are conducted
after uvicorn has setup the event loop.
  • Loading branch information
peterschutt committed Jan 12, 2023
1 parent 8f6c80b commit 40e832b
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 80 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ignore-words-list = "alog"

[tool.coverage.run]
branch = true
omit = ["*/starlite_saqlalchemy/scripts.py", "tests/*"]
omit = ["*/starlite_saqlalchemy/scripts.py", "*/starlite_saqlalchemy/lifespan.py", "tests/*"]
relative_files = true
source_pkgs = ["starlite_saqlalchemy"]

Expand Down
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ sonar.organization=topsport-com-au
sonar.test.inclusions=tests/**/*.py
sonar.sources=src
sonar.sourceEncoding=UTF-8
sonar.coverage.exclusions=src/starlite_saqlalchemy/scripts.py
sonar.coverage.exclusions=src/starlite_saqlalchemy/scripts.py,src/starlite_saqlalchemy/lifespan.py
sonar.cpd.exclusions=alembic/**/*
sonar.python.version=3.11
sonar.python.coverage.reportPaths=coverage.xml
2 changes: 2 additions & 0 deletions src/starlite_saqlalchemy/init_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def example_handler() -> dict:
dependencies,
exceptions,
http,
lifespan,
log,
openapi,
redis,
Expand Down Expand Up @@ -198,6 +199,7 @@ def __call__(self, app_config: AppConfig) -> AppConfig:
self.configure_type_encoders(app_config)
self.configure_worker(app_config)

app_config.before_startup = lifespan.before_startup_handler
app_config.on_shutdown.extend([http.on_shutdown, redis.client.close])
return app_config

Expand Down
45 changes: 45 additions & 0 deletions src/starlite_saqlalchemy/lifespan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Application lifespan handlers."""
# pylint: disable=broad-except
import asyncio
import logging

import starlite
from sqlalchemy import text

from starlite_saqlalchemy import redis
from starlite_saqlalchemy.db import engine

logger = logging.getLogger(__name__)


async def _db_ready() -> None:
"""Wait for database to become responsive."""
while True:
try:
async with engine.begin() as conn:
await conn.execute(text("SELECT 1"))
except Exception as exc:
logger.info("Waiting for DB: %s", exc)
await asyncio.sleep(5)
else:
logger.info("DB OK!")
break


async def _redis_ready() -> None:
"""Wait for redis to become responsive."""
while True:
try:
await redis.client.ping()
except Exception as exc:
logger.info("Waiting for Redis: %s", exc)
await asyncio.sleep(5)
else:
logger.info("Redis OK!")
break


async def before_startup_handler(_: starlite.Starlite) -> None:
"""Do things before the app starts up."""
await _db_ready()
await _redis_ready()
89 changes: 23 additions & 66 deletions src/starlite_saqlalchemy/scripts.py
Original file line number Diff line number Diff line change
@@ -1,84 +1,41 @@
"""Application startup script."""
# pragma: no cover
# pylint: disable=broad-except
import argparse
import asyncio

import uvicorn
import uvloop
from sqlalchemy import text

from starlite_saqlalchemy import redis, settings
from starlite_saqlalchemy.db import engine
from starlite_saqlalchemy import settings

asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

def determine_should_reload() -> bool:
"""Evaluate whether reloading should be enabled."""
return (
settings.server.RELOAD
if settings.server.RELOAD is not None
else settings.app.ENVIRONMENT == "local"
)

async def _db_ready() -> None:
"""Wait for database to become responsive."""
while True:
try:
async with engine.begin() as conn:
await conn.execute(text("SELECT 1"))
except Exception as exc:
print(f"Waiting for DB: {exc}")
await asyncio.sleep(5)
else:
print("DB OK!")
break

def determine_reload_dirs(should_reload: bool) -> list[str] | None:
"""
async def _redis_ready() -> None:
"""Wait for redis to become responsive."""
while True:
try:
await redis.client.ping()
except Exception as exc:
print(f"Waiting for Redis: {exc}")
await asyncio.sleep(5)
else:
print("Redis OK!")
break
Args:
should_reload: is reloading enabled?
Returns:
List of directories to watch, or `None` if reloading disabled.
"""
return settings.server.RELOAD_DIRS if should_reload else None

def _get_uvicorn_config() -> uvicorn.Config:
reload = (
settings.server.RELOAD
if settings.server.RELOAD is not None
else settings.app.ENVIRONMENT == "local"
)
reload_dirs = settings.server.RELOAD_DIRS if reload else None

return uvicorn.Config(
def run_app() -> None:
"""Run the application with config via environment."""
should_reload = determine_should_reload()
reload_dirs = determine_reload_dirs(should_reload)
uvicorn.run(
app=settings.server.APP_LOC,
factory=settings.server.APP_LOC_IS_FACTORY,
host=settings.server.HOST,
loop="none",
loop="auto",
port=settings.server.PORT,
reload=reload,
reload=should_reload,
reload_dirs=reload_dirs,
timeout_keep_alive=settings.server.KEEPALIVE,
)


async def _run_server(config: uvicorn.Config) -> None:
"""Run an uvicorn server with the given config."""
server = uvicorn.Server(config)
await server.serve()


def run_app() -> None:
"""Run the application."""
parser = argparse.ArgumentParser(description="Run the application")
parser.add_argument("--no-db", action="store_const", const=False, default=True, dest="check_db")
parser.add_argument(
"--no-cache", action="store_const", const=False, default=True, dest="check_cache"
)
args = parser.parse_args()

with asyncio.Runner() as runner:
if args.check_db:
runner.run(_db_ready())
if args.check_cache:
runner.run(_redis_ready())
runner.run(_run_server(_get_uvicorn_config()))
3 changes: 3 additions & 0 deletions tests/integration/test_authors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from httpx import AsyncClient


@pytest.mark.xfail()
async def test_update_author(client: AsyncClient) -> None:
"""Integration test for PUT route."""
response = await client.put(
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
if TYPE_CHECKING:
from collections import abc

from pytest import MonkeyPatch
from starlite import Starlite
from starlite.types import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope

Expand Down Expand Up @@ -91,6 +92,13 @@ def fx_book_repository(
return book_repository_type()


@pytest.fixture(name="app")
def fx_app(app: Starlite, monkeypatch: MonkeyPatch) -> Starlite:
"""Remove service readiness checks for unit tests."""
monkeypatch.setattr(app, "before_startup", [])
return app


@pytest.fixture(name="client")
def fx_client(app: Starlite) -> abc.Iterator[TestClient]:
"""Client instance attached to app.
Expand Down
22 changes: 10 additions & 12 deletions tests/unit/test_scripts.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""Tests for scripts.py."""

from pathlib import Path

import pytest

from starlite_saqlalchemy import settings
from starlite_saqlalchemy.scripts import _get_uvicorn_config
from starlite_saqlalchemy.scripts import determine_reload_dirs, determine_should_reload
from starlite_saqlalchemy.testing import modify_settings


Expand All @@ -15,15 +13,15 @@ def test_uvicorn_config_auto_reload_local(reload: bool | None, expected: bool) -
with modify_settings(
(settings.app, {"ENVIRONMENT": "local"}), (settings.server, {"RELOAD": reload})
):
config = _get_uvicorn_config()
assert config.reload is expected
assert determine_should_reload() is expected


@pytest.mark.parametrize(
("reload", "expected"), [(None, []), (True, settings.server.RELOAD_DIRS), (False, [])]
)
def test_uvicorn_config_reload_dirs(reload: bool | None, expected: list[str]) -> None:
@pytest.mark.parametrize("reload", [True, False])
def test_uvicorn_config_reload_dirs(reload: bool) -> None:
"""Test that RELOAD_DIRS is only used when RELOAD is enabled."""
with modify_settings((settings.server, {"RELOAD": reload})):
config = _get_uvicorn_config()
assert config.reload_dirs == [Path(path).absolute() for path in expected]
if not reload:
assert determine_reload_dirs(reload) is None
else:
reload_dirs = determine_reload_dirs(reload)
assert reload_dirs is not None
assert reload_dirs == settings.server.RELOAD_DIRS

0 comments on commit 40e832b

Please sign in to comment.