This commit is contained in:
Eden Kirin
2023-09-14 21:03:05 +02:00
parent 4e43d3be66
commit db8fba7207
7 changed files with 232 additions and 38 deletions

49
.env.template Normal file
View File

@ -0,0 +1,49 @@
# App
BUILD_NUMBER=0
DEBUG=true
ENVIRONMENT=local
LOG_LEVEL=INFO
NAME=addressbook
# Api
API_CACHE_EXPIRATION=60
API_DB_SESSION_DEPENDENCY_KEY=db_session
API_DEFAULT_PAGINATION_LIMIT=100
API_DEFAULT_USER_NAME="__default_user__"
API_HEALTH_PATH=/health
API_SECRET_KEY=super-secret-value
API_USER_DEPENDENCY_KEY=user
# OpenAPI
OPENAPI_CONTACT_EMAIL=some_human@email.com
OPENAPI_CONTACT_NAME="Some Human"
OPENAPI_TITLE="My Litestar App"
OPENAPI_VERSION=1.0.0
# Database
DB_ECHO=true
DB_ECHO_POOL=false
DB_POOL_DISABLE=false
DB_POOL_MAX_OVERFLOW=10
DB_POOL_SIZE=5
DB_POOL_TIMEOUT=30
DB_HOST=localhost
DB_PORT=5432
DB_NAME=addressbook
DB_USER=addressbook
DB_PASSWORD=addressbook
# Server
UVICORN_HOST=0.0.0.0
UVICORN_KEEPALIVE=65
UVICORN_LOG_LEVEL=info
UVICORN_PORT=8000
UVICORN_RELOAD=true
UVICORN_TIMEOUT=65
# Email
EMAIL_HOST=mailhog
EMAIL_NEW_AUTHOR_SUBJECT="New Author Added"
EMAIL_PORT=1025
EMAIL_RECIPIENT=someone@somewhere.com
EMAIL_SENDER=root@localhost

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
/.venv /.venv
__pycache__ __pycache__
/.env

110
app/lib/settings.py Normal file
View File

@ -0,0 +1,110 @@
"""All configuration via environment.
Take note of the environment variable prefixes required for each
settings class, except `AppSettings`.
"""
from typing import Literal, Optional, Union
__all__ = [
"APISettings",
"AppSettings",
"DatabaseSettings",
"EmailSettings",
"OpenAPISettings",
"ServerSettings",
]
from pydantic import Extra
from pydantic_settings import BaseSettings
class BaseEnvSettings(BaseSettings):
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = Extra.ignore
class AppSettings(BaseEnvSettings):
class Config:
case_sensitive = True
BUILD_NUMBER: str = "0"
DEBUG: bool = False
ENVIRONMENT: str = "local"
LOG_LEVEL: str = "INFO"
NAME: str = "addressbook"
@property
def slug(self) -> str:
return "-".join(s.lower() for s in self.NAME.split())
class APISettings(BaseEnvSettings):
class Config:
env_prefix = "API_"
case_sensitive = True
CACHE_EXPIRATION: int = 60
DB_SESSION_DEPENDENCY_KEY: str = "db_session"
DEFAULT_PAGINATION_LIMIT: int = 100
DEFAULT_USER_NAME: str = "__default_user__"
HEALTH_PATH: str = "/health"
SECRET_KEY: str = "abc123"
USER_DEPENDENCY_KEY: str = "user"
class OpenAPISettings(BaseEnvSettings):
class Config:
env_prefix = "OPENAPI_"
case_sensitive = True
TITLE: Optional[str] = "My Litestar App"
VERSION: str = "0.1.0"
CONTACT_NAME: str = "My Name"
CONTACT_EMAIL: str = "some_human@some_domain.com"
class DatabaseSettings(BaseEnvSettings):
class Config:
env_prefix = "DB_"
case_sensitive = True
ECHO: bool = False
ECHO_POOL: Union[bool, Literal["debug"]] = False
POOL_DISABLE: bool = False
POOL_MAX_OVERFLOW: int = 10
POOL_SIZE: int = 5
POOL_TIMEOUT: int = 30
HOST: str = "localhost"
PORT: int = 5432
NAME: str = "db-name"
USER: str = "db-user"
PASSWORD: str = "db-password"
class ServerSettings(BaseEnvSettings):
class Config:
env_prefix = "UVICORN_"
case_sensitive = True
HOST: str = "localhost"
LOG_LEVEL: str = "info"
PORT: int = 8000
RELOAD: bool = True
KEEPALIVE: int = 65
class EmailSettings(BaseEnvSettings):
class Config:
env_prefix = "EMAIL_"
case_sensitive = True
# `.parse_obj()` thing is a workaround for pyright and pydantic interplay, see:
# https://github.com/pydantic/pydantic/issues/3753#issuecomment-1087417884
api = APISettings.parse_obj({})
app = AppSettings.parse_obj({})
db = DatabaseSettings.parse_obj({})
openapi = OpenAPISettings.parse_obj({})
server = ServerSettings.parse_obj({})

View File

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, cast, Literal
from uuid import UUID from uuid import UUID
import msgspec import msgspec
import sqlalchemy
from litestar.contrib.sqlalchemy.plugins.init import SQLAlchemyInitPlugin from litestar.contrib.sqlalchemy.plugins.init import SQLAlchemyInitPlugin
from litestar.contrib.sqlalchemy.plugins.init.config import SQLAlchemyAsyncConfig from litestar.contrib.sqlalchemy.plugins.init.config import SQLAlchemyAsyncConfig
from litestar.contrib.sqlalchemy.plugins.init.config.common import ( from litestar.contrib.sqlalchemy.plugins.init.config.common import (
@ -16,28 +16,7 @@ from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from app.lib import settings
DB_HOST = "localhost"
DB_PORT = 5432
DB_NAME = "addressbook"
DB_USER = "addressbook"
DB_PASSWORD = "addressbook"
@dataclass
class DatabaseSettings:
URL: str = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
ECHO: bool = True
ECHO_POOL: bool | Literal["debug"] = False
POOL_DISABLE: bool = False
POOL_MAX_OVERFLOW: int = 10
POOL_SIZE: int = 5
POOL_TIMEOUT: int = 30
DB_SESSION_DEPENDENCY_KEY: str = "db_session"
settings = DatabaseSettings()
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
@ -58,15 +37,24 @@ def _default(val: Any) -> str:
raise TypeError() raise TypeError()
db_connection_url = sqlalchemy.engine.URL.create(
drivername="postgresql+asyncpg",
username=settings.db.USER,
password=settings.db.PASSWORD,
host=settings.db.HOST,
port=settings.db.PORT,
database=settings.db.NAME,
)
engine = create_async_engine( engine = create_async_engine(
settings.URL, db_connection_url,
echo=settings.ECHO, echo=settings.db.ECHO,
echo_pool=settings.ECHO_POOL, echo_pool=settings.db.ECHO_POOL,
json_serializer=msgspec.json.Encoder(enc_hook=_default), json_serializer=msgspec.json.Encoder(enc_hook=_default),
max_overflow=settings.POOL_MAX_OVERFLOW, max_overflow=settings.db.POOL_MAX_OVERFLOW,
pool_size=settings.POOL_SIZE, pool_size=settings.db.POOL_SIZE,
pool_timeout=settings.POOL_TIMEOUT, pool_timeout=settings.db.POOL_TIMEOUT,
poolclass=NullPool if settings.POOL_DISABLE else None, poolclass=NullPool if settings.db.POOL_DISABLE else None,
) )
"""Configure via DatabaseSettings. """Configure via DatabaseSettings.
@ -74,7 +62,9 @@ Overrides default JSON
serializer to use `msgspec`. See [`create_async_engine()`][sqlalchemy.ext.asyncio.create_async_engine] serializer to use `msgspec`. See [`create_async_engine()`][sqlalchemy.ext.asyncio.create_async_engine]
for detailed instructions. for detailed instructions.
""" """
async_session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) async_session_factory = async_sessionmaker(
engine, expire_on_commit=False, class_=AsyncSession
)
"""Database session factory. """Database session factory.
See [`async_sessionmaker()`][sqlalchemy.ext.asyncio.async_sessionmaker]. See [`async_sessionmaker()`][sqlalchemy.ext.asyncio.async_sessionmaker].
@ -124,10 +114,11 @@ async def before_send_handler(message: Message, scope: Scope) -> None:
Args: Args:
message: ASGI message message: ASGI message
_:
scope: ASGI scope scope: ASGI scope
""" """
session = cast("AsyncSession | None", get_litestar_scope_state(scope, SESSION_SCOPE_KEY)) session = cast(
"AsyncSession | None", get_litestar_scope_state(scope, SESSION_SCOPE_KEY)
)
try: try:
if session is not None and message["type"] == "http.response.start": if session is not None and message["type"] == "http.response.start":
if 200 <= message["status"] < 300: if 200 <= message["status"] < 300:
@ -141,7 +132,7 @@ async def before_send_handler(message: Message, scope: Scope) -> None:
config = SQLAlchemyAsyncConfig( config = SQLAlchemyAsyncConfig(
session_dependency_key=settings.DB_SESSION_DEPENDENCY_KEY, session_dependency_key=settings.api.DB_SESSION_DEPENDENCY_KEY,
engine_instance=engine, engine_instance=engine,
session_maker=async_session_factory, session_maker=async_session_factory,
before_send_handler=before_send_handler, before_send_handler=before_send_handler,

17
main.py
View File

@ -1,5 +1,6 @@
from typing import Any from typing import Any
import uvicorn
from litestar import Litestar from litestar import Litestar
from litestar.contrib.repository.exceptions import ( from litestar.contrib.repository.exceptions import (
RepositoryError as RepositoryException, RepositoryError as RepositoryException,
@ -7,10 +8,13 @@ from litestar.contrib.repository.exceptions import (
from litestar.openapi import OpenAPIConfig from litestar.openapi import OpenAPIConfig
from app.controllers import create_router from app.controllers import create_router
from app.lib import exceptions, sqlalchemy_plugin from app.lib import exceptions, settings, sqlalchemy_plugin
from app.lib.service import ServiceError from app.lib.service import ServiceError
def create_app(**kwargs: Any) -> Litestar: def create_app(**kwargs: Any) -> Litestar:
kwargs.setdefault("debug", settings.app.DEBUG)
return Litestar( return Litestar(
route_handlers=[create_router()], route_handlers=[create_router()],
openapi_config=OpenAPIConfig(title="My API", version="1.0.0"), openapi_config=OpenAPIConfig(title="My API", version="1.0.0"),
@ -20,9 +24,18 @@ def create_app(**kwargs: Any) -> Litestar:
RepositoryException: exceptions.repository_exception_to_http_response, # type: ignore[dict-item] RepositoryException: exceptions.repository_exception_to_http_response, # type: ignore[dict-item]
ServiceError: exceptions.service_exception_to_http_response, # type: ignore[dict-item] ServiceError: exceptions.service_exception_to_http_response, # type: ignore[dict-item]
}, },
debug=True,
**kwargs, **kwargs,
) )
app = create_app() app = create_app()
if __name__ == "__main__":
uvicorn.run(
app,
host=settings.server.HOST,
log_level=settings.server.LOG_LEVEL,
port=settings.server.PORT,
reload=settings.server.RELOAD,
timeout_keep_alive=settings.server.KEEPALIVE,
)

31
poetry.lock generated
View File

@ -664,6 +664,21 @@ files = [
[package.dependencies] [package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-settings"
version = "2.0.3"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.7"
files = [
{file = "pydantic_settings-2.0.3-py3-none-any.whl", hash = "sha256:ddd907b066622bd67603b75e2ff791875540dc485b7307c4fffc015719da8625"},
{file = "pydantic_settings-2.0.3.tar.gz", hash = "sha256:962dc3672495aad6ae96a4390fac7e593591e144625e5112d359f8f67fb75945"},
]
[package.dependencies]
pydantic = ">=2.0.1"
python-dotenv = ">=0.21.0"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.2" version = "7.4.2"
@ -698,6 +713,20 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "1.0.0"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"},
{file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.1" version = "6.0.1"
@ -913,4 +942,4 @@ anyio = ">=3.0.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "3319e3383cccbf806026c4983b44d301eb3106d9afc729328a5adfce7332ef8e" content-hash = "28c7f9f4b78e7d602b076108d3138c854cd1a1bc22c447987a6aedbba6300dcd"

View File

@ -12,6 +12,7 @@ sqlalchemy = "^2.0.20"
uvicorn = "^0.23.2" uvicorn = "^0.23.2"
asyncpg = "^0.28.0" asyncpg = "^0.28.0"
pydantic = "^2.3.0" pydantic = "^2.3.0"
pydantic-settings = "^2.0.3"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]