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
__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 dataclasses import dataclass
from typing import TYPE_CHECKING, cast, Literal
from typing import TYPE_CHECKING, cast
from uuid import UUID
import msgspec
import sqlalchemy
from litestar.contrib.sqlalchemy.plugins.init import SQLAlchemyInitPlugin
from litestar.contrib.sqlalchemy.plugins.init.config import SQLAlchemyAsyncConfig
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.pool import NullPool
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()
from app.lib import settings
if TYPE_CHECKING:
from typing import Any
@ -58,15 +37,24 @@ def _default(val: Any) -> str:
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(
settings.URL,
echo=settings.ECHO,
echo_pool=settings.ECHO_POOL,
db_connection_url,
echo=settings.db.ECHO,
echo_pool=settings.db.ECHO_POOL,
json_serializer=msgspec.json.Encoder(enc_hook=_default),
max_overflow=settings.POOL_MAX_OVERFLOW,
pool_size=settings.POOL_SIZE,
pool_timeout=settings.POOL_TIMEOUT,
poolclass=NullPool if settings.POOL_DISABLE else None,
max_overflow=settings.db.POOL_MAX_OVERFLOW,
pool_size=settings.db.POOL_SIZE,
pool_timeout=settings.db.POOL_TIMEOUT,
poolclass=NullPool if settings.db.POOL_DISABLE else None,
)
"""Configure via DatabaseSettings.
@ -74,7 +62,9 @@ Overrides default JSON
serializer to use `msgspec`. See [`create_async_engine()`][sqlalchemy.ext.asyncio.create_async_engine]
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.
See [`async_sessionmaker()`][sqlalchemy.ext.asyncio.async_sessionmaker].
@ -124,10 +114,11 @@ async def before_send_handler(message: Message, scope: Scope) -> None:
Args:
message: ASGI message
_:
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:
if session is not None and message["type"] == "http.response.start":
if 200 <= message["status"] < 300:
@ -141,7 +132,7 @@ async def before_send_handler(message: Message, scope: Scope) -> None:
config = SQLAlchemyAsyncConfig(
session_dependency_key=settings.DB_SESSION_DEPENDENCY_KEY,
session_dependency_key=settings.api.DB_SESSION_DEPENDENCY_KEY,
engine_instance=engine,
session_maker=async_session_factory,
before_send_handler=before_send_handler,

17
main.py
View File

@ -1,5 +1,6 @@
from typing import Any
import uvicorn
from litestar import Litestar
from litestar.contrib.repository.exceptions import (
RepositoryError as RepositoryException,
@ -7,10 +8,13 @@ from litestar.contrib.repository.exceptions import (
from litestar.openapi import OpenAPIConfig
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
def create_app(**kwargs: Any) -> Litestar:
kwargs.setdefault("debug", settings.app.DEBUG)
return Litestar(
route_handlers=[create_router()],
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]
ServiceError: exceptions.service_exception_to_http_response, # type: ignore[dict-item]
},
debug=True,
**kwargs,
)
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]
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]]
name = "pytest"
version = "7.4.2"
@ -698,6 +713,20 @@ files = [
[package.dependencies]
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]]
name = "pyyaml"
version = "6.0.1"
@ -913,4 +942,4 @@ anyio = ">=3.0.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "3319e3383cccbf806026c4983b44d301eb3106d9afc729328a5adfce7332ef8e"
content-hash = "28c7f9f4b78e7d602b076108d3138c854cd1a1bc22c447987a6aedbba6300dcd"

View File

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