From db8fba7207bbd233577f06934ba9716a3d3adb3a Mon Sep 17 00:00:00 2001 From: Eden Kirin Date: Thu, 14 Sep 2023 21:03:05 +0200 Subject: [PATCH] Settings --- .env.template | 49 ++++++++++++++++ .gitignore | 1 + app/lib/settings.py | 110 +++++++++++++++++++++++++++++++++++ app/lib/sqlalchemy_plugin.py | 61 +++++++++---------- main.py | 17 +++++- poetry.lock | 31 +++++++++- pyproject.toml | 1 + 7 files changed, 232 insertions(+), 38 deletions(-) create mode 100644 .env.template create mode 100644 app/lib/settings.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..2945876 --- /dev/null +++ b/.env.template @@ -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 diff --git a/.gitignore b/.gitignore index e849f1a..423be66 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.venv __pycache__ +/.env diff --git a/app/lib/settings.py b/app/lib/settings.py new file mode 100644 index 0000000..8b6036e --- /dev/null +++ b/app/lib/settings.py @@ -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({}) diff --git a/app/lib/sqlalchemy_plugin.py b/app/lib/sqlalchemy_plugin.py index 3b30614..cf18e69 100644 --- a/app/lib/sqlalchemy_plugin.py +++ b/app/lib/sqlalchemy_plugin.py @@ -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, diff --git a/main.py b/main.py index 6070b5f..d0dc248 100644 --- a/main.py +++ b/main.py @@ -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, + ) diff --git a/poetry.lock b/poetry.lock index b15cf2b..d58a12e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 8ad6ebf..46ce95a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]