From 44eccbf32201526c8a4c8c3f301f5ff96bef77ed Mon Sep 17 00:00:00 2001 From: James Bursa Date: Mon, 8 May 2023 16:16:07 -0400 Subject: [PATCH 01/16] Experiment: multi-environment configuration in Python --- app/src/__main__.py | 19 +++++---- app/src/app.py | 6 +-- app/src/app_config.py | 2 +- app/src/config/__init__.py | 40 +++++++++++++++++++ app/src/config/default.py | 26 ++++++++++++ app/src/config/env/dev.py | 9 +++++ app/src/config/env/local.py | 16 ++++++++ app/src/config/env/prod.py | 9 +++++ app/src/config/env/staging.py | 9 +++++ app/src/logging/__init__.py | 4 +- app/src/logging/config.py | 33 ++++++++++----- app/src/util/env_config.py | 35 +++++++++++----- app/tests/conftest.py | 32 +++++++++++++-- app/tests/lib/db_testing.py | 24 +++++------ .../db/clients/test_postgres_client.py | 4 +- app/tests/src/adapters/db/test_db.py | 13 +++--- app/tests/src/adapters/db/test_flask_db.py | 4 +- app/tests/src/logging/test_logging.py | 11 ++--- docker-compose.yml | 5 +-- 19 files changed, 231 insertions(+), 70 deletions(-) create mode 100644 app/src/config/__init__.py create mode 100644 app/src/config/default.py create mode 100644 app/src/config/env/dev.py create mode 100644 app/src/config/env/local.py create mode 100644 app/src/config/env/prod.py create mode 100644 app/src/config/env/staging.py diff --git a/app/src/__main__.py b/app/src/__main__.py index 725a6f2d..1b6a426e 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -6,8 +6,10 @@ # https://docs.python.org/3/library/__main__.html import logging +import os import src.app +import src.config import src.logging from src.app_config import AppConfig from src.util.local import load_local_env_vars @@ -16,23 +18,26 @@ def main() -> None: - load_local_env_vars() - app_config = AppConfig() + config = src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) - app = src.app.create_app() + app = src.app.create_app(config) + logger.info("loaded configuration", extra={"config": config}) - environment = app_config.environment + all_configs = src.config.load_all() + logger.info("loaded all", extra={"all_configs": all_configs}) + + environment = config.app.environment # When running in a container, the host needs to be set to 0.0.0.0 so that the app can be # accessed from outside the container. See Dockerfile - host = app_config.host - port = app_config.port + host = config.app.host + port = config.app.port logger.info( "Running API Application", extra={"environment": environment, "host": host, "port": port} ) - if app_config.environment == "local": + if config.app.environment == "local": # If python files are changed, the app will auto-reload # Note this doesn't have the OpenAPI yaml file configured at the moment app.run(host=host, port=port, use_reloader=True, reloader_type="stat") diff --git a/app/src/app.py b/app/src/app.py index e8857183..e5344cf7 100644 --- a/app/src/app.py +++ b/app/src/app.py @@ -18,13 +18,13 @@ logger = logging.getLogger(__name__) -def create_app() -> APIFlask: +def create_app(config) -> APIFlask: app = APIFlask(__name__) - src.logging.init(__package__) + src.logging.init(__package__, config.logging) flask_logger.init_app(logging.root, app) - db_client = db.PostgresDBClient() + db_client = db.PostgresDBClient(config.database) flask_db.register_db_client(db_client, app) configure_app(app) diff --git a/app/src/app_config.py b/app/src/app_config.py index 44159955..f9e1ec8a 100644 --- a/app/src/app_config.py +++ b/app/src/app_config.py @@ -2,7 +2,7 @@ class AppConfig(PydanticBaseEnvConfig): - environment: str + environment: str = "unknown" # Set HOST to 127.0.0.1 by default to avoid other machines on the network # from accessing the application. This is especially important if you are diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py new file mode 100644 index 00000000..86753cbe --- /dev/null +++ b/app/src/config/__init__.py @@ -0,0 +1,40 @@ +# +# Multi-environment configuration expressed in Python. +# + +import importlib +import logging +import pathlib + +from src.adapters.db.clients.postgres_config import PostgresDBConfig +from src.app_config import AppConfig +from src.logging.config import LoggingConfig +from src.util.env_config import PydanticBaseEnvConfig + + +logger = logging.getLogger(__name__) + + +class RootConfig(PydanticBaseEnvConfig): + app: AppConfig + database: PostgresDBConfig + logging: LoggingConfig + + +def load(environment_name, environ=None) -> RootConfig: + """Load the configuration for the given environment name.""" + logger.info("loading configuration", extra={"environment": environment_name}) + module = importlib.import_module(name=".env." + environment_name, package=__package__) + config = module.config + + if environ: + config.override_from_environment(environ) + config.app.environment = environment_name + + return config + + +def load_all() -> dict[str, RootConfig]: + """Load all environment configurations, to ensure they are valid. Used in tests.""" + directory = pathlib.Path(__file__).parent / "env" + return {item.stem: load(str(item.stem)) for item in directory.glob("*.py")} diff --git a/app/src/config/default.py b/app/src/config/default.py new file mode 100644 index 00000000..e49d057d --- /dev/null +++ b/app/src/config/default.py @@ -0,0 +1,26 @@ +# +# Default configuration. +# +# This is the base layer of configuration. It is used if an environment does not override a value. +# Each environment may override individual values (see local.py, dev.py, prod.py, etc.). +# +# This configuration is also used when running tests (individual test cases may have code to use +# different configuration). +# + +from src.adapters.db.clients.postgres_config import PostgresDBConfig +from src.app_config import AppConfig +from src.logging.config import LoggingConfig +from src.config import RootConfig + + +def default_config(): + return RootConfig( + app=AppConfig(), + database=PostgresDBConfig(), + logging=LoggingConfig( + format="json", + level="INFO", + enable_audit=True, + ), + ) diff --git a/app/src/config/env/dev.py b/app/src/config/env/dev.py new file mode 100644 index 00000000..1bece4be --- /dev/null +++ b/app/src/config/env/dev.py @@ -0,0 +1,9 @@ +# +# Configuration for dev environments. +# +# This file only contains overrides (differences) from the defaults in default.py. +# + +from .. import default + +config = default.default_config() diff --git a/app/src/config/env/local.py b/app/src/config/env/local.py new file mode 100644 index 00000000..9281601b --- /dev/null +++ b/app/src/config/env/local.py @@ -0,0 +1,16 @@ +# +# Configuration for local development environments. +# +# This file only contains overrides (differences) from the defaults in default.py. +# + +import pydantic.types + +from .. import default + +config = default.default_config() + +config.database.password = pydantic.types.SecretStr("secret123") +config.database.hide_sql_parameter_logs = False +config.logging.format = "human_readable" +config.logging.enable_audit = False diff --git a/app/src/config/env/prod.py b/app/src/config/env/prod.py new file mode 100644 index 00000000..6dd8e827 --- /dev/null +++ b/app/src/config/env/prod.py @@ -0,0 +1,9 @@ +# +# Configuration for prod environment. +# +# This file only contains overrides (differences) from the defaults in default.py. +# + +from .. import default + +config = default.default_config() diff --git a/app/src/config/env/staging.py b/app/src/config/env/staging.py new file mode 100644 index 00000000..44db8eb4 --- /dev/null +++ b/app/src/config/env/staging.py @@ -0,0 +1,9 @@ +# +# Configuration for staging environment. +# +# This file only contains overrides (differences) from the defaults in default.py. +# + +from .. import default + +config = default.default_config() diff --git a/app/src/logging/__init__.py b/app/src/logging/__init__.py index efe17b42..5f8353a8 100644 --- a/app/src/logging/__init__.py +++ b/app/src/logging/__init__.py @@ -28,5 +28,5 @@ import src.logging.config as config -def init(program_name: str) -> config.LoggingContext: - return config.LoggingContext(program_name) +def init(program_name: str, logging_config: config.LoggingConfig) -> config.LoggingContext: + return config.LoggingContext(program_name, logging_config) diff --git a/app/src/logging/config.py b/app/src/logging/config.py index 7108dc93..18fa146a 100644 --- a/app/src/logging/config.py +++ b/app/src/logging/config.py @@ -1,3 +1,4 @@ +import enum import logging import os import platform @@ -5,6 +6,8 @@ import sys from typing import Any, ContextManager, cast +import pydantic + import src.logging.audit import src.logging.formatters as formatters import src.logging.pii as pii @@ -15,15 +18,27 @@ _original_argv = tuple(sys.argv) +class LoggingFormat(str, enum.Enum): + json = "json" + human_readable = "human_readable" + + class HumanReadableFormatterConfig(PydanticBaseEnvConfig): message_width: int = formatters.HUMAN_READABLE_FORMATTER_DEFAULT_MESSAGE_WIDTH class LoggingConfig(PydanticBaseEnvConfig): - format = "json" - level = "INFO" - enable_audit = True - human_readable_formatter = HumanReadableFormatterConfig() + format: LoggingFormat = LoggingFormat.json + level: str = "INFO" + enable_audit: bool = True + human_readable_formatter: HumanReadableFormatterConfig = HumanReadableFormatterConfig() + + @pydantic.validator("level") + def valid_level(cls, v): + value = logging.getLevelName(v) + if not isinstance(value, int): + raise ValueError("invalid logging level %s" % v) + return v class Config: env_prefix = "log_" @@ -58,8 +73,8 @@ class LoggingContext(ContextManager[None]): and calling this multiple times before exit would result in duplicate logs. """ - def __init__(self, program_name: str) -> None: - self._configure_logging() + def __init__(self, program_name: str, config: LoggingConfig) -> None: + self._configure_logging(config) log_program_info(program_name) def __enter__(self) -> None: @@ -72,14 +87,14 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # of those tests. logging.root.removeHandler(self.console_handler) - def _configure_logging(self) -> None: + def _configure_logging(self, config: LoggingConfig) -> None: """Configure logging for the application. Configures the root module logger to log to stdout. Adds a PII mask filter to the root logger. Also configures log levels third party packages. """ - config = LoggingConfig() + # config = LoggingConfig() # TODO inject # Loggers can be configured using config functions defined # in logging.config or by directly making calls to the main API @@ -112,7 +127,7 @@ def get_formatter(config: LoggingConfig) -> logging.Formatter: The formatter is determined by the environment variable LOG_FORMAT. If the environment variable is not set, the JSON formatter is used by default. """ - if config.format == "human-readable": + if config.format == LoggingFormat.human_readable: return get_human_readable_formatter(config.human_readable_formatter) return formatters.JsonFormatter() diff --git a/app/src/util/env_config.py b/app/src/util/env_config.py index cd9c4dc3..2b85ec12 100644 --- a/app/src/util/env_config.py +++ b/app/src/util/env_config.py @@ -1,16 +1,31 @@ -import os +import logging -from pydantic import BaseSettings +from pydantic import BaseModel -import src -env_file = os.path.join( - os.path.dirname(os.path.dirname(src.__file__)), - "config", - "%s.env" % os.getenv("ENVIRONMENT", "local"), -) +logger = logging.getLogger(__name__) -class PydanticBaseEnvConfig(BaseSettings): +class PydanticBaseEnvConfig(BaseModel): + """Base class for application configuration. + + Similar to Pydantic's BaseSettings class, but we implement our own method to override from the + environment so that it can be run later, after an instance was constructed.""" + class Config: - env_file = env_file + validate_assignment = True + + def override_from_environment(self, environ, prefix=""): + """Recursively override field values from the given environment variable mapping.""" + for name, field in self.__fields__.items(): + if field.is_complex(): + # Nested models must be instances of this class too. + getattr(self, name).override_from_environment(environ, prefix=name + "_") + continue + + env_var_name = field.field_info.extra.get("env", prefix + name) + for key in (env_var_name, env_var_name.lower(), env_var_name.upper()): + if key in environ: + logging.info("override from environment", extra={"key": key}) + setattr(self, field.name, environ[key]) + break diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 9a9ed6dc..c7319d27 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -1,4 +1,6 @@ import logging +import os +import uuid import _pytest.monkeypatch import boto3 @@ -6,10 +8,13 @@ import flask.testing import moto import pytest +import pydantic.types import src.adapters.db as db +from src.adapters.db.clients.postgres_config import PostgresDBConfig import src.app as app_entry import tests.src.db.models.factories as factories +import src.config from src.db import models from src.util.local import load_local_env_vars from tests.lib import db_testing @@ -69,7 +74,26 @@ def monkeypatch_module(): @pytest.fixture(scope="session") -def db_client(monkeypatch_session) -> db.DBClient: +def config() -> src.config.RootConfig: + schema_name = f"test_schema_{uuid.uuid4().int}" + + config = src.config.load("local") + config.database.db_schema = schema_name + + # Allow host to be overridden when running in docker-compose. + if "DB_HOST" in os.environ: + config.database.host = os.environ["DB_HOST"] + + return config + + +@pytest.fixture(scope="session") +def db_config(config) -> PostgresDBConfig: + return config.database + + +@pytest.fixture(scope="session") +def db_client(monkeypatch_session, db_config) -> db.DBClient: """ Creates an isolated database for the test session. @@ -79,7 +103,7 @@ def db_client(monkeypatch_session) -> db.DBClient: after the test suite session completes. """ - with db_testing.create_isolated_db(monkeypatch_session) as db_client: + with db_testing.create_isolated_db(db_config) as db_client: models.metadata.create_all(bind=db_client.get_connection()) yield db_client @@ -114,8 +138,8 @@ def enable_factory_create(monkeypatch, db_session) -> db.Session: # Make app session scoped so the database connection pool is only created once # for the test session. This speeds up the tests. @pytest.fixture(scope="session") -def app(db_client) -> flask.Flask: - return app_entry.create_app() +def app(db_client, config) -> flask.Flask: + return app_entry.create_app(config) @pytest.fixture diff --git a/app/tests/lib/db_testing.py b/app/tests/lib/db_testing.py index 13bd25c6..25fc971f 100644 --- a/app/tests/lib/db_testing.py +++ b/app/tests/lib/db_testing.py @@ -1,44 +1,38 @@ """Helper functions for testing database code.""" import contextlib import logging +import os import uuid +import pydantic.types + import src.adapters.db as db -from src.adapters.db.clients.postgres_config import get_db_config +from src.adapters.db.clients.postgres_config import PostgresDBConfig logger = logging.getLogger(__name__) @contextlib.contextmanager -def create_isolated_db(monkeypatch) -> db.DBClient: +def create_isolated_db(db_config: PostgresDBConfig) -> db.DBClient: """ Creates a temporary PostgreSQL schema and creates a database engine that connects to that schema. Drops the schema after the context manager exits. """ - schema_name = f"test_schema_{uuid.uuid4().int}" - monkeypatch.setenv("DB_SCHEMA", schema_name) - monkeypatch.setenv("POSTGRES_DB", "main-db") - monkeypatch.setenv("POSTGRES_USER", "local_db_user") - monkeypatch.setenv("POSTGRES_PASSWORD", "secret123") - monkeypatch.setenv("ENVIRONMENT", "local") - monkeypatch.setenv("DB_CHECK_CONNECTION_ON_INIT", "False") # To improve test performance, don't check the database connection # when initializing the DB client. - db_client = db.PostgresDBClient() + db_client = db.PostgresDBClient(db_config) with db_client.get_connection() as conn: - _create_schema(conn, schema_name) + _create_schema(conn, db_config.db_schema, db_config.username) try: yield db_client finally: - _drop_schema(conn, schema_name) + _drop_schema(conn, db_config.db_schema) -def _create_schema(conn: db.Connection, schema_name: str): +def _create_schema(conn: db.Connection, schema_name: str, db_test_user: str): """Create a database schema.""" - db_test_user = get_db_config().username - conn.execute(f"CREATE SCHEMA IF NOT EXISTS {schema_name} AUTHORIZATION {db_test_user};") logger.info("create schema %s", schema_name) diff --git a/app/tests/src/adapters/db/clients/test_postgres_client.py b/app/tests/src/adapters/db/clients/test_postgres_client.py index f04c0e97..c4618f54 100644 --- a/app/tests/src/adapters/db/clients/test_postgres_client.py +++ b/app/tests/src/adapters/db/clients/test_postgres_client.py @@ -8,7 +8,7 @@ make_connection_uri, verify_ssl, ) -from src.adapters.db.clients.postgres_config import PostgresDBConfig, get_db_config +from src.adapters.db.clients.postgres_config import PostgresDBConfig class DummyConnectionInfo: @@ -47,7 +47,7 @@ def test_verify_ssl_not_in_use(caplog): "username_password_port,expected", zip( # Test all combinations of username, password, and port - product(["testuser", ""], ["testpass", None], ["5432", ""]), + product(["testuser", ""], ["testpass", None], [5432, None]), [ "postgresql://testuser:testpass@localhost:5432/dbname?options=-csearch_path=public", "postgresql://testuser:testpass@localhost/dbname?options=-csearch_path=public", diff --git a/app/tests/src/adapters/db/test_db.py b/app/tests/src/adapters/db/test_db.py index f3ddc99a..9b602727 100644 --- a/app/tests/src/adapters/db/test_db.py +++ b/app/tests/src/adapters/db/test_db.py @@ -5,19 +5,20 @@ def test_db_connection(db_client): - db_client = db.PostgresDBClient() + # db_client = db.PostgresDBClient() with db_client.get_connection() as conn: assert conn.scalar(text("SELECT 1")) == 1 -def test_check_db_connection(caplog, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("DB_CHECK_CONNECTION_ON_INIT", "True") - db.PostgresDBClient() +def test_check_db_connection(caplog, monkeypatch: pytest.MonkeyPatch, db_config): + db_config = db_config.copy() + db_config.check_connection_on_init = True + db.PostgresDBClient(db_config) assert "database connection is not using SSL" in caplog.messages -def test_get_session(): - db_client = db.PostgresDBClient() +def test_get_session(db_client): + # db_client = db.PostgresDBClient() with db_client.get_session() as session: with session.begin(): assert session.scalar(text("SELECT 1")) == 1 diff --git a/app/tests/src/adapters/db/test_flask_db.py b/app/tests/src/adapters/db/test_flask_db.py index c95ee12d..b35c90e0 100644 --- a/app/tests/src/adapters/db/test_flask_db.py +++ b/app/tests/src/adapters/db/test_flask_db.py @@ -9,9 +9,9 @@ # Define an isolated example Flask app fixture specific to this test module # to avoid dependencies on any project-specific fixtures in conftest.py @pytest.fixture -def example_app() -> Flask: +def example_app(config) -> Flask: app = Flask(__name__) - db_client = db.PostgresDBClient() + db_client = db.PostgresDBClient(config.database) flask_db.register_db_client(db_client, app) return app diff --git a/app/tests/src/logging/test_logging.py b/app/tests/src/logging/test_logging.py index 6991ce37..01254749 100644 --- a/app/tests/src/logging/test_logging.py +++ b/app/tests/src/logging/test_logging.py @@ -5,29 +5,30 @@ import src.logging import src.logging.formatters as formatters +from src.logging.config import LoggingConfig, LoggingFormat from tests.lib.assertions import assert_dict_contains @pytest.fixture def init_test_logger(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch): caplog.set_level(logging.DEBUG) - monkeypatch.setenv("LOG_FORMAT", "human-readable") - with src.logging.init("test_logging"): + logging_config = LoggingConfig(format=LoggingFormat.human_readable) + with src.logging.init("test_logging", logging_config): yield @pytest.mark.parametrize( "log_format,expected_formatter", [ - ("human-readable", formatters.HumanReadableFormatter), + ("human_readable", formatters.HumanReadableFormatter), ("json", formatters.JsonFormatter), ], ) def test_init(caplog: pytest.LogCaptureFixture, monkeypatch, log_format, expected_formatter): caplog.set_level(logging.DEBUG) - monkeypatch.setenv("LOG_FORMAT", log_format) + logging_config = LoggingConfig(format=log_format) - with src.logging.init("test_logging"): + with src.logging.init("test_logging", logging_config): records = caplog.records assert len(records) == 2 diff --git a/docker-compose.yml b/docker-compose.yml index 13bcc57e..32907b4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,8 @@ services: container_name: main-app - # Load environment variables for local development - env_file: ./app/local.env - # NOTE: These values take precedence if the same value is specified in the env_file. environment: - # The env_file defines DB_HOST=localhost for accessing a non-dockerized database. + # The code defines DB_HOST=localhost for accessing a non-dockerized database. # In the docker-compose, we tell the app to use the dockerized database service - DB_HOST=main-db ports: From 17f93c8907d87c2da951c34889705dc746d7e911 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Mon, 8 May 2023 17:37:08 -0400 Subject: [PATCH 02/16] Experiment: multi-environment configuration in Python --- .../adapters/db/clients/postgres_client.py | 18 +++++++------- .../adapters/db/clients/postgres_config.py | 24 +++---------------- app/src/config/__init__.py | 1 - app/src/config/default.py | 2 +- app/src/util/env_config.py | 1 - app/tests/conftest.py | 6 ++--- 6 files changed, 15 insertions(+), 37 deletions(-) diff --git a/app/src/adapters/db/clients/postgres_client.py b/app/src/adapters/db/clients/postgres_client.py index d31cdea3..9193cbd9 100644 --- a/app/src/adapters/db/clients/postgres_client.py +++ b/app/src/adapters/db/clients/postgres_client.py @@ -8,7 +8,7 @@ import sqlalchemy.pool as pool from src.adapters.db.client import DBClient -from src.adapters.db.clients.postgres_config import PostgresDBConfig, get_db_config +from src.adapters.db.clients.postgres_config import PostgresDBConfig logger = logging.getLogger(__name__) @@ -19,9 +19,7 @@ class PostgresDBClient(DBClient): as configured by parameters passed in from the db_config """ - def __init__(self, db_config: PostgresDBConfig | None = None) -> None: - if not db_config: - db_config = get_db_config() + def __init__(self, db_config: PostgresDBConfig) -> None: self._engine = self._configure_engine(db_config) if db_config.check_connection_on_init: @@ -82,17 +80,17 @@ def check_db_connection(self) -> None: def get_connection_parameters(db_config: PostgresDBConfig) -> dict[str, Any]: connect_args = {} environment = os.getenv("ENVIRONMENT") - if not environment: - raise Exception("ENVIRONMENT is not set") - - if environment != "local": - connect_args["sslmode"] = "require" + # if not environment: + # raise Exception("ENVIRONMENT is not set") + # + # if environment != "local": + # connect_args["sslmode"] = "require" return dict( host=db_config.host, dbname=db_config.name, user=db_config.username, - password=db_config.password, + password=db_config.password.get_secret_value(), port=db_config.port, options=f"-c search_path={db_config.db_schema}", connect_timeout=3, diff --git a/app/src/adapters/db/clients/postgres_config.py b/app/src/adapters/db/clients/postgres_config.py index d78b2a10..06d32b76 100644 --- a/app/src/adapters/db/clients/postgres_config.py +++ b/app/src/adapters/db/clients/postgres_config.py @@ -1,6 +1,7 @@ import logging from typing import Optional +import pydantic from pydantic import Field from src.util.env_config import PydanticBaseEnvConfig @@ -13,26 +14,7 @@ class PostgresDBConfig(PydanticBaseEnvConfig): host: str = Field("localhost", env="DB_HOST") name: str = Field("main-db", env="POSTGRES_DB") username: str = Field("local_db_user", env="POSTGRES_USER") - password: Optional[str] = Field(..., env="POSTGRES_PASSWORD") + password: Optional[pydantic.types.SecretStr] = Field(None, env="POSTGRES_PASSWORD") db_schema: str = Field("public", env="DB_SCHEMA") - port: str = Field("5432", env="DB_PORT") + port: int = Field(5432, env="DB_PORT") hide_sql_parameter_logs: bool = Field(True, env="HIDE_SQL_PARAMETER_LOGS") - - -def get_db_config() -> PostgresDBConfig: - db_config = PostgresDBConfig() - - logger.info( - "Constructed database configuration", - extra={ - "host": db_config.host, - "dbname": db_config.name, - "username": db_config.username, - "password": "***" if db_config.password is not None else None, - "db_schema": db_config.db_schema, - "port": db_config.port, - "hide_sql_parameter_logs": db_config.hide_sql_parameter_logs, - }, - ) - - return db_config diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py index 86753cbe..c693de91 100644 --- a/app/src/config/__init__.py +++ b/app/src/config/__init__.py @@ -11,7 +11,6 @@ from src.logging.config import LoggingConfig from src.util.env_config import PydanticBaseEnvConfig - logger = logging.getLogger(__name__) diff --git a/app/src/config/default.py b/app/src/config/default.py index e49d057d..7598d891 100644 --- a/app/src/config/default.py +++ b/app/src/config/default.py @@ -10,8 +10,8 @@ from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.app_config import AppConfig -from src.logging.config import LoggingConfig from src.config import RootConfig +from src.logging.config import LoggingConfig def default_config(): diff --git a/app/src/util/env_config.py b/app/src/util/env_config.py index 2b85ec12..862dedc7 100644 --- a/app/src/util/env_config.py +++ b/app/src/util/env_config.py @@ -2,7 +2,6 @@ from pydantic import BaseModel - logger = logging.getLogger(__name__) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index c7319d27..8361a0a1 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -7,14 +7,14 @@ import flask import flask.testing import moto -import pytest import pydantic.types +import pytest import src.adapters.db as db -from src.adapters.db.clients.postgres_config import PostgresDBConfig import src.app as app_entry -import tests.src.db.models.factories as factories import src.config +import tests.src.db.models.factories as factories +from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.db import models from src.util.local import load_local_env_vars from tests.lib import db_testing From 3d4da448b2fb680fa8a55a29b1cbfa294e225009 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Tue, 9 May 2023 11:57:18 -0400 Subject: [PATCH 03/16] Add tests and fix minor bugs --- app/Dockerfile | 2 +- app/src/__main__.py | 13 ++++--- app/src/config/__init__.py | 4 +-- app/src/logging/config.py | 1 - app/src/util/env_config.py | 2 +- app/tests/src/config/__init__.py | 0 app/tests/src/config/test_config.py | 53 +++++++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 app/tests/src/config/__init__.py create mode 100644 app/tests/src/config/test_config.py diff --git a/app/Dockerfile b/app/Dockerfile index 9ec44fbb..19b1e1d9 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -28,7 +28,7 @@ COPY . /srv # Set the host to 0.0.0.0 to make the server available external # to the Docker container that it's running in. -ENV HOST=0.0.0.0 +ENV APP_HOST=0.0.0.0 # Install application dependencies. # https://python-poetry.org/docs/basic-usage/#installing-dependencies diff --git a/app/src/__main__.py b/app/src/__main__.py index 1b6a426e..97ca0d24 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -11,8 +11,6 @@ import src.app import src.config import src.logging -from src.app_config import AppConfig -from src.util.local import load_local_env_vars logger = logging.getLogger(__package__) @@ -40,10 +38,17 @@ def main() -> None: if config.app.environment == "local": # If python files are changed, the app will auto-reload # Note this doesn't have the OpenAPI yaml file configured at the moment - app.run(host=host, port=port, use_reloader=True, reloader_type="stat") + app.run( + host=host, + port=port, + debug=True, + load_dotenv=False, + use_reloader=True, + reloader_type="stat", + ) else: # Don't enable the reloader if non-local - app.run(host=host, port=port) + app.run(host=host, port=port, load_dotenv=False) main() diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py index c693de91..1ab5196d 100644 --- a/app/src/config/__init__.py +++ b/app/src/config/__init__.py @@ -22,9 +22,9 @@ class RootConfig(PydanticBaseEnvConfig): def load(environment_name, environ=None) -> RootConfig: """Load the configuration for the given environment name.""" - logger.info("loading configuration", extra={"environment": environment_name}) + logger.debug("loading configuration", extra={"environment": environment_name}) module = importlib.import_module(name=".env." + environment_name, package=__package__) - config = module.config + config = module.config.copy(deep=True) if environ: config.override_from_environment(environ) diff --git a/app/src/logging/config.py b/app/src/logging/config.py index 18fa146a..667a3b22 100644 --- a/app/src/logging/config.py +++ b/app/src/logging/config.py @@ -94,7 +94,6 @@ def _configure_logging(self, config: LoggingConfig) -> None: Adds a PII mask filter to the root logger. Also configures log levels third party packages. """ - # config = LoggingConfig() # TODO inject # Loggers can be configured using config functions defined # in logging.config or by directly making calls to the main API diff --git a/app/src/util/env_config.py b/app/src/util/env_config.py index 862dedc7..578539a5 100644 --- a/app/src/util/env_config.py +++ b/app/src/util/env_config.py @@ -25,6 +25,6 @@ def override_from_environment(self, environ, prefix=""): env_var_name = field.field_info.extra.get("env", prefix + name) for key in (env_var_name, env_var_name.lower(), env_var_name.upper()): if key in environ: - logging.info("override from environment", extra={"key": key}) + # logging.debug("override from environment", extra={"key": key}) setattr(self, field.name, environ[key]) break diff --git a/app/tests/src/config/__init__.py b/app/tests/src/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/tests/src/config/test_config.py b/app/tests/src/config/test_config.py new file mode 100644 index 00000000..2cfe56f5 --- /dev/null +++ b/app/tests/src/config/test_config.py @@ -0,0 +1,53 @@ +# +# Tests for src.config. +# + +import pydantic.error_wrappers +import pytest + +from src import config + + +def test_load_with_override(): + conf = config.load( + environment_name="local", environ={"app_host": "test.host", "app_port": 999, "port": 888} + ) + + assert isinstance(conf, config.RootConfig) + assert conf.app.host == "test.host" + assert conf.app.port == 999 + + +def test_load_with_invalid_override(): + with pytest.raises(pydantic.error_wrappers.ValidationError): + config.load(environment_name="local", environ={"app_port": "not_a_number"}) + + +def test_load(): + conf = config.load(environment_name="local") + + assert isinstance(conf, config.RootConfig) + assert conf.app.host == "127.0.0.1" + + +def test_load_invalid_environment_name(): + with pytest.raises(ModuleNotFoundError): + config.load(environment_name="does_not_exist") + + +def test_load_all(): + """This test is important to confirm that all configurations are valid - otherwise we would + not know until runtime in the appropriate environment.""" + + all_configs = config.load_all() + + # We expect at least these configs to exist - there may be others too. + assert all_configs.keys() >= {"local", "dev", "prod"} + + for value in all_configs.values(): + assert isinstance(value, config.RootConfig) + + # Make sure they are all different objects (prevent bugs where they overwrite by accidental + # sharing). + ids = {id(value) for value in all_configs.values()} + assert len(ids) == len(all_configs) From 2da8195ac045f52e5c7bb42c9c2ae8a3259478f2 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Tue, 9 May 2023 17:07:18 -0400 Subject: [PATCH 04/16] Fix GitHub formats --- app/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Makefile b/app/Makefile index 40e328d8..87e1f654 100644 --- a/app/Makefile +++ b/app/Makefile @@ -16,9 +16,9 @@ DECODE_LOG := 2>&1 | python3 -u src/logging/util/decodelog.py # TODO - when CI gets hooked up, actually test this. ifdef CI DOCKER_EXEC_ARGS := -T -e CI -e PYTEST_ADDOPTS="--color=yes" - FLAKE8_FORMAT := '::warning file=src/%(path)s,line=%(row)d,col=%(col)d::%(path)s:%(row)d:%(col)d: %(code)s %(text)s' + FLAKE8_FORMAT := '::warning file=app/%(path)s,line=%(row)d,col=%(col)d::%(path)s:%(row)d:%(col)d: %(code)s %(text)s' MYPY_FLAGS := --no-pretty - MYPY_POSTPROC := | perl -pe "s/^(.+):(\d+):(\d+): error: (.*)/::warning file=src\/\1,line=\2,col=\3::\4/" + MYPY_POSTPROC := | perl -pe "s/^(.+):(\d+):(\d+): error: (.*)/::warning file=app\/\1,line=\2,col=\3::\4/" else FLAKE8_FORMAT := default endif From 4194db8d14a564ea929afda6dfe40f5acc877993 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 14:12:35 -0400 Subject: [PATCH 05/16] Fix lint and test failures and fix db-upgrade --- .../adapters/db/clients/postgres_client.py | 18 +++---------- .../adapters/db/clients/postgres_config.py | 1 + app/src/config/default.py | 4 +-- app/src/config/env/local.py | 4 ++- app/src/db/migrations/env.py | 9 ++++--- app/src/logging/config.py | 2 +- app/tests/lib/db_testing.py | 4 --- .../db/clients/test_postgres_client.py | 27 +++++++------------ app/tests/src/adapters/db/test_flask_db.py | 4 +-- app/tests/src/db/test_migrations.py | 19 ++++++++++--- app/tests/src/logging/test_logging.py | 2 +- 11 files changed, 45 insertions(+), 49 deletions(-) diff --git a/app/src/adapters/db/clients/postgres_client.py b/app/src/adapters/db/clients/postgres_client.py index 9193cbd9..64876e87 100644 --- a/app/src/adapters/db/clients/postgres_client.py +++ b/app/src/adapters/db/clients/postgres_client.py @@ -1,5 +1,4 @@ import logging -import os import urllib.parse from typing import Any @@ -78,14 +77,6 @@ def check_db_connection(self) -> None: def get_connection_parameters(db_config: PostgresDBConfig) -> dict[str, Any]: - connect_args = {} - environment = os.getenv("ENVIRONMENT") - # if not environment: - # raise Exception("ENVIRONMENT is not set") - # - # if environment != "local": - # connect_args["sslmode"] = "require" - return dict( host=db_config.host, dbname=db_config.name, @@ -94,7 +85,7 @@ def get_connection_parameters(db_config: PostgresDBConfig) -> dict[str, Any]: port=db_config.port, options=f"-c search_path={db_config.db_schema}", connect_timeout=3, - **connect_args, + sslmode=db_config.sslmode, ) @@ -107,7 +98,7 @@ def make_connection_uri(config: PostgresDBConfig) -> str: host = config.host db_name = config.name username = config.username - password = urllib.parse.quote(config.password) if config.password else None + password = urllib.parse.quote(config.password.get_secret_value()) if config.password else None schema = config.db_schema port = config.port @@ -120,10 +111,7 @@ def make_connection_uri(config: PostgresDBConfig) -> str: elif password: netloc_parts.append(f":{password}@") - netloc_parts.append(host) - - if port: - netloc_parts.append(f":{port}") + netloc_parts.append(f"{host}:{port}") netloc = "".join(netloc_parts) diff --git a/app/src/adapters/db/clients/postgres_config.py b/app/src/adapters/db/clients/postgres_config.py index 06d32b76..8fca8816 100644 --- a/app/src/adapters/db/clients/postgres_config.py +++ b/app/src/adapters/db/clients/postgres_config.py @@ -18,3 +18,4 @@ class PostgresDBConfig(PydanticBaseEnvConfig): db_schema: str = Field("public", env="DB_SCHEMA") port: int = Field(5432, env="DB_PORT") hide_sql_parameter_logs: bool = Field(True, env="HIDE_SQL_PARAMETER_LOGS") + sslmode: str = "require" diff --git a/app/src/config/default.py b/app/src/config/default.py index 7598d891..3dee3ec2 100644 --- a/app/src/config/default.py +++ b/app/src/config/default.py @@ -11,7 +11,7 @@ from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.app_config import AppConfig from src.config import RootConfig -from src.logging.config import LoggingConfig +from src.logging.config import LoggingConfig, LoggingFormat def default_config(): @@ -19,7 +19,7 @@ def default_config(): app=AppConfig(), database=PostgresDBConfig(), logging=LoggingConfig( - format="json", + format=LoggingFormat.json, level="INFO", enable_audit=True, ), diff --git a/app/src/config/env/local.py b/app/src/config/env/local.py index 9281601b..4ca03194 100644 --- a/app/src/config/env/local.py +++ b/app/src/config/env/local.py @@ -7,10 +7,12 @@ import pydantic.types from .. import default +from src.logging.config import LoggingFormat config = default.default_config() config.database.password = pydantic.types.SecretStr("secret123") config.database.hide_sql_parameter_logs = False -config.logging.format = "human_readable" +config.database.sslmode = "prefer" +config.logging.format = LoggingFormat.human_readable config.logging.enable_audit = False diff --git a/app/src/db/migrations/env.py b/app/src/db/migrations/env.py index a45a584f..94a5fd61 100644 --- a/app/src/db/migrations/env.py +++ b/app/src/db/migrations/env.py @@ -1,4 +1,5 @@ import logging +import os import sys from typing import Any @@ -16,8 +17,8 @@ load_local_env_vars() from src.adapters.db.clients.postgres_client import make_connection_uri # noqa: E402 isort:skip -from src.adapters.db.clients.postgres_config import get_db_config # noqa: E402 isort:skip from src.db.models import metadata # noqa: E402 isort:skip +import src.config import src.logging # noqa: E402 isort:skip # this is the Alembic Config object, which provides @@ -26,11 +27,13 @@ logger = logging.getLogger("migrations") +root_config = src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) + # Initialize logging -with src.logging.init("migrations"): +with src.logging.init("migrations", root_config.logging): if not config.get_main_option("sqlalchemy.url"): - uri = make_connection_uri(get_db_config()) + uri = make_connection_uri(root_config.database) # Escape percentage signs in the URI. # https://alembic.sqlalchemy.org/en/latest/api/config.html#alembic.config.Config.set_main_option diff --git a/app/src/logging/config.py b/app/src/logging/config.py index 667a3b22..78ea92f1 100644 --- a/app/src/logging/config.py +++ b/app/src/logging/config.py @@ -34,7 +34,7 @@ class LoggingConfig(PydanticBaseEnvConfig): human_readable_formatter: HumanReadableFormatterConfig = HumanReadableFormatterConfig() @pydantic.validator("level") - def valid_level(cls, v): + def valid_level(cls, v): # noqa: B902 value = logging.getLevelName(v) if not isinstance(value, int): raise ValueError("invalid logging level %s" % v) diff --git a/app/tests/lib/db_testing.py b/app/tests/lib/db_testing.py index 25fc971f..b102623e 100644 --- a/app/tests/lib/db_testing.py +++ b/app/tests/lib/db_testing.py @@ -1,10 +1,6 @@ """Helper functions for testing database code.""" import contextlib import logging -import os -import uuid - -import pydantic.types import src.adapters.db as db from src.adapters.db.clients.postgres_config import PostgresDBConfig diff --git a/app/tests/src/adapters/db/clients/test_postgres_client.py b/app/tests/src/adapters/db/clients/test_postgres_client.py index c4618f54..5735aa2a 100644 --- a/app/tests/src/adapters/db/clients/test_postgres_client.py +++ b/app/tests/src/adapters/db/clients/test_postgres_client.py @@ -2,6 +2,7 @@ from itertools import product import pytest +from pydantic.types import SecretStr from src.adapters.db.clients.postgres_client import ( get_connection_parameters, @@ -47,16 +48,16 @@ def test_verify_ssl_not_in_use(caplog): "username_password_port,expected", zip( # Test all combinations of username, password, and port - product(["testuser", ""], ["testpass", None], [5432, None]), + product(["testuser", ""], ["testpass", None], [5432, 5433]), [ "postgresql://testuser:testpass@localhost:5432/dbname?options=-csearch_path=public", - "postgresql://testuser:testpass@localhost/dbname?options=-csearch_path=public", + "postgresql://testuser:testpass@localhost:5433/dbname?options=-csearch_path=public", "postgresql://testuser@localhost:5432/dbname?options=-csearch_path=public", - "postgresql://testuser@localhost/dbname?options=-csearch_path=public", + "postgresql://testuser@localhost:5433/dbname?options=-csearch_path=public", "postgresql://:testpass@localhost:5432/dbname?options=-csearch_path=public", - "postgresql://:testpass@localhost/dbname?options=-csearch_path=public", + "postgresql://:testpass@localhost:5433/dbname?options=-csearch_path=public", "postgresql://localhost:5432/dbname?options=-csearch_path=public", - "postgresql://localhost/dbname?options=-csearch_path=public", + "postgresql://localhost:5433/dbname?options=-csearch_path=public", ], ), ) @@ -77,23 +78,15 @@ def test_make_connection_uri(username_password_port, expected): ) -def test_get_connection_parameters_require_environment(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("ENVIRONMENT") - db_config = get_db_config() - with pytest.raises(Exception, match="ENVIRONMENT is not set"): - get_connection_parameters(db_config) - - -def test_get_connection_parameters(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("ENVIRONMENT", "production") - db_config = get_db_config() +def test_get_connection_parameters(): + db_config = PostgresDBConfig(host="test1", password=SecretStr("test_password_123")) conn_params = get_connection_parameters(db_config) assert conn_params == dict( - host=db_config.host, + host="test1", dbname=db_config.name, user=db_config.username, - password=db_config.password, + password="test_password_123", port=db_config.port, options=f"-c search_path={db_config.db_schema}", connect_timeout=3, diff --git a/app/tests/src/adapters/db/test_flask_db.py b/app/tests/src/adapters/db/test_flask_db.py index b35c90e0..5810ada7 100644 --- a/app/tests/src/adapters/db/test_flask_db.py +++ b/app/tests/src/adapters/db/test_flask_db.py @@ -37,8 +37,8 @@ def hello(db_session: db.Session): assert response.get_json() == {"data": "hello, world"} -def test_with_db_session_not_default_name(example_app: Flask): - db_client = db.PostgresDBClient() +def test_with_db_session_not_default_name(example_app: Flask, db_config): + db_client = db.PostgresDBClient(db_config) flask_db.register_db_client(db_client, example_app, client_name="something_else") @example_app.route("/hello") diff --git a/app/tests/src/db/test_migrations.py b/app/tests/src/db/test_migrations.py index 160a8766..de16341a 100644 --- a/app/tests/src/db/test_migrations.py +++ b/app/tests/src/db/test_migrations.py @@ -1,4 +1,5 @@ import logging # noqa: B1 +import uuid import pytest from alembic import command @@ -7,12 +8,20 @@ from alembic.util.exc import CommandError import src.adapters.db as db +from src.adapters.db.clients.postgres_client import make_connection_uri from src.db.migrations.run import alembic_cfg from tests.lib import db_testing @pytest.fixture -def empty_schema(monkeypatch) -> db.DBClient: +def empty_db_config(db_config): + empty_db_config = db_config.copy() + empty_db_config.db_schema = f"test_schema_{uuid.uuid4().int}" + return empty_db_config + + +@pytest.fixture +def empty_schema(empty_db_config) -> db.DBClient: """ Create a test schema, if it doesn't already exist, and drop it after the test completes. @@ -20,7 +29,7 @@ def empty_schema(monkeypatch) -> db.DBClient: This is similar to what the db_client fixture does but does not create any tables in the schema. """ - with db_testing.create_isolated_db(monkeypatch) as db_client: + with db_testing.create_isolated_db(empty_db_config) as db_client: yield db_client @@ -47,8 +56,12 @@ def test_only_single_head_revision_in_migrations(): ) -def test_db_setup_via_alembic_migration(empty_schema, caplog: pytest.LogCaptureFixture): +def test_db_setup_via_alembic_migration(empty_db_config, empty_schema, caplog: pytest.LogCaptureFixture): caplog.set_level(logging.INFO) # noqa: B1 + + uri = make_connection_uri(empty_db_config) + alembic_cfg.set_main_option("sqlalchemy.url", uri.replace("%", "%%")) + command.upgrade(alembic_cfg, "head") # Verify the migration ran by checking the logs assert "Running upgrade" in caplog.text diff --git a/app/tests/src/logging/test_logging.py b/app/tests/src/logging/test_logging.py index 01254749..acf89b50 100644 --- a/app/tests/src/logging/test_logging.py +++ b/app/tests/src/logging/test_logging.py @@ -26,7 +26,7 @@ def init_test_logger(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.Monke ) def test_init(caplog: pytest.LogCaptureFixture, monkeypatch, log_format, expected_formatter): caplog.set_level(logging.DEBUG) - logging_config = LoggingConfig(format=log_format) + logging_config = LoggingConfig(format=log_format, enable_audit=False) with src.logging.init("test_logging", logging_config): From d1c90d288f18cd816038618e116fe33068029718 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 14:23:19 -0400 Subject: [PATCH 06/16] Format and lint fixes --- app/local.env | 76 ----------------------------- app/src/config/env/local.py | 3 +- app/src/db/migrations/env.py | 11 ++--- app/src/util/local.py | 22 --------- app/tests/conftest.py | 28 ----------- app/tests/src/db/test_migrations.py | 4 +- docker-compose.yml | 2 - 7 files changed, 9 insertions(+), 137 deletions(-) delete mode 100644 app/local.env delete mode 100644 app/src/util/local.py diff --git a/app/local.env b/app/local.env deleted file mode 100644 index 04c30a3a..00000000 --- a/app/local.env +++ /dev/null @@ -1,76 +0,0 @@ -# Local environment variables -# Used by docker-compose and it can be loaded -# by calling load_local_env_vars() from app/src/util/local.py - -ENVIRONMENT=local -PORT=8080 - -# Python path needs to be specified -# for pytest to find the implementation code -PYTHONPATH=/app/ - -# PY_RUN_APPROACH=python OR docker -# Set this in your environment -# to modify how the Makefile runs -# commands that can run in or out -# of the Docker container - defaults to outside - -FLASK_APP=src.app:create_app - -############################ -# Logging -############################ - -# Can be "human-readable" OR "json" -LOG_FORMAT=human-readable - -# Set log level. Valid values are DEBUG, INFO, WARNING, CRITICAL -# LOG_LEVEL=INFO - -# Enable/disable audit logging. Valid values are TRUE, FALSE -LOG_ENABLE_AUDIT=FALSE - -# Change the message length for the human readable formatter -# LOG_HUMAN_READABLE_FORMATTER__MESSAGE_WIDTH=50 - -############################ -# Authentication -############################ -# The auth token used by the local endpoints -API_AUTH_TOKEN=LOCAL_AUTH_12345678 - -############################ -# DB Environment Variables -############################ -POSTGRES_DB=main-db -POSTGRES_USER=local_db_user -POSTGRES_PASSWORD=secret123 - -# Note that this is only used when running -# commands outside of the Docker container -# and is overriden when running inside by the -# value specified in the docker-compose file -DB_HOST=localhost - -# When an error occurs with a SQL query, -# whether or not to hide the parameters which -# could contain sensitive information. -HIDE_SQL_PARAMETER_LOGS=TRUE - -############################ -# AWS Defaults -############################ -# For these secret access keys, don't -# add them to this file to avoid mistakenly -# committing them. Set these in your shell -# by doing `export AWS_ACCESS_KEY_ID=whatever` -AWS_ACCESS_KEY_ID=DO_NOT_SET_HERE -AWS_SECRET_ACCESS_KEY=DO_NOT_SET_HERE -# These next two are commented out as we -# don't have configuration for individuals -# to use these at the moment and boto3 -# tries to use them first before the keys above. -#AWS_SECURITY_TOKEN=DO_NOT_SET_HERE -#AWS_SESSION_TOKEN=DO_NOT_SET_HERE - -AWS_DEFAULT_REGION=us-east-1 diff --git a/app/src/config/env/local.py b/app/src/config/env/local.py index 4ca03194..ce3c82fb 100644 --- a/app/src/config/env/local.py +++ b/app/src/config/env/local.py @@ -6,9 +6,10 @@ import pydantic.types -from .. import default from src.logging.config import LoggingFormat +from .. import default + config = default.default_config() config.database.password = pydantic.types.SecretStr("secret123") diff --git a/app/src/db/migrations/env.py b/app/src/db/migrations/env.py index 94a5fd61..25704c07 100644 --- a/app/src/db/migrations/env.py +++ b/app/src/db/migrations/env.py @@ -11,14 +11,9 @@ # See database migrations section in `./database/database-migrations.md` for details about running migrations. sys.path.insert(0, ".") # noqa: E402 -# Load env vars before anything further -from src.util.local import load_local_env_vars # noqa: E402 isort:skip - -load_local_env_vars() - from src.adapters.db.clients.postgres_client import make_connection_uri # noqa: E402 isort:skip from src.db.models import metadata # noqa: E402 isort:skip -import src.config +import src.config # noqa: E402 isort:skip import src.logging # noqa: E402 isort:skip # this is the Alembic Config object, which provides @@ -27,7 +22,9 @@ logger = logging.getLogger("migrations") -root_config = src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) +root_config = src.config.load( + environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ +) # Initialize logging with src.logging.init("migrations", root_config.logging): diff --git a/app/src/util/local.py b/app/src/util/local.py deleted file mode 100644 index 0f1e0a29..00000000 --- a/app/src/util/local.py +++ /dev/null @@ -1,22 +0,0 @@ -import os - -from dotenv import load_dotenv - - -def load_local_env_vars(env_file: str = "local.env") -> None: - """ - Load environment variables from the local.env so - that they can be fetched with `os.getenv()` or with - other utils that pull env vars. - - https://pypi.org/project/python-dotenv/ - - NOTE: any existing env vars will not be overriden by this - """ - environment = os.getenv("ENVIRONMENT", None) - - # If the environment is explicitly local or undefined - # we'll use the dotenv file, otherwise we'll skip - # Should never run if not local development - if environment is None or environment == "local": - load_dotenv(env_file) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 8361a0a1..f663e956 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -4,10 +4,8 @@ import _pytest.monkeypatch import boto3 -import flask import flask.testing import moto -import pydantic.types import pytest import src.adapters.db as db @@ -16,37 +14,11 @@ import tests.src.db.models.factories as factories from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.db import models -from src.util.local import load_local_env_vars from tests.lib import db_testing logger = logging.getLogger(__name__) -@pytest.fixture(scope="session", autouse=True) -def env_vars(): - """ - Default environment variables for tests to be - based on the local.env file. These get set once - before all tests run. As "session" is the highest - scope, this will run before any other explicit fixtures - in a test. - - See: https://docs.pytest.org/en/6.2.x/fixture.html#autouse-order - - To set a different environment variable for a test, - use the monkeypatch fixture, for example: - - ```py - def test_example(monkeypatch): - monkeypatch.setenv("LOG_LEVEL", "debug") - ``` - - Several monkeypatch fixtures exists below for different - scope levels. - """ - load_local_env_vars() - - #################### # Test DB session #################### diff --git a/app/tests/src/db/test_migrations.py b/app/tests/src/db/test_migrations.py index de16341a..6bb56df1 100644 --- a/app/tests/src/db/test_migrations.py +++ b/app/tests/src/db/test_migrations.py @@ -56,7 +56,9 @@ def test_only_single_head_revision_in_migrations(): ) -def test_db_setup_via_alembic_migration(empty_db_config, empty_schema, caplog: pytest.LogCaptureFixture): +def test_db_setup_via_alembic_migration( + empty_db_config, empty_schema, caplog: pytest.LogCaptureFixture +): caplog.set_level(logging.INFO) # noqa: B1 uri = make_connection_uri(empty_db_config) diff --git a/docker-compose.yml b/docker-compose.yml index 32907b4f..882a3303 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,6 @@ services: container_name: main-db command: postgres -c "log_lock_waits=on" -N 1000 -c "fsync=off" - # Load environment variables for local development. - env_file: ./app/local.env ports: - "5432:5432" volumes: From e86e683db3228186918bedcadc647170353ae413 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 14:37:41 -0400 Subject: [PATCH 07/16] Add type annotations --- app/src/adapters/db/clients/postgres_client.py | 2 +- app/src/app.py | 3 ++- app/src/config/__init__.py | 3 ++- app/src/config/default.py | 2 +- app/src/logging/config.py | 2 +- app/src/util/env_config.py | 3 ++- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/adapters/db/clients/postgres_client.py b/app/src/adapters/db/clients/postgres_client.py index 64876e87..fd5152f5 100644 --- a/app/src/adapters/db/clients/postgres_client.py +++ b/app/src/adapters/db/clients/postgres_client.py @@ -81,7 +81,7 @@ def get_connection_parameters(db_config: PostgresDBConfig) -> dict[str, Any]: host=db_config.host, dbname=db_config.name, user=db_config.username, - password=db_config.password.get_secret_value(), + password=db_config.password.get_secret_value() if db_config.password else None, port=db_config.port, options=f"-c search_path={db_config.db_schema}", connect_timeout=3, diff --git a/app/src/app.py b/app/src/app.py index e5344cf7..6aab7e1c 100644 --- a/app/src/app.py +++ b/app/src/app.py @@ -14,11 +14,12 @@ from src.api.schemas import response_schema from src.api.users import user_blueprint from src.auth.api_key_auth import User, get_app_security_scheme +from src.config import RootConfig logger = logging.getLogger(__name__) -def create_app(config) -> APIFlask: +def create_app(config: RootConfig) -> APIFlask: app = APIFlask(__name__) src.logging.init(__package__, config.logging) diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py index 1ab5196d..1ce442d9 100644 --- a/app/src/config/__init__.py +++ b/app/src/config/__init__.py @@ -5,6 +5,7 @@ import importlib import logging import pathlib +from typing import Optional, Mapping from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.app_config import AppConfig @@ -20,7 +21,7 @@ class RootConfig(PydanticBaseEnvConfig): logging: LoggingConfig -def load(environment_name, environ=None) -> RootConfig: +def load(environment_name: str, environ: Optional[Mapping[str, str]] = None) -> RootConfig: """Load the configuration for the given environment name.""" logger.debug("loading configuration", extra={"environment": environment_name}) module = importlib.import_module(name=".env." + environment_name, package=__package__) diff --git a/app/src/config/default.py b/app/src/config/default.py index 3dee3ec2..3878b8d2 100644 --- a/app/src/config/default.py +++ b/app/src/config/default.py @@ -14,7 +14,7 @@ from src.logging.config import LoggingConfig, LoggingFormat -def default_config(): +def default_config() -> RootConfig: return RootConfig( app=AppConfig(), database=PostgresDBConfig(), diff --git a/app/src/logging/config.py b/app/src/logging/config.py index 78ea92f1..36dc502c 100644 --- a/app/src/logging/config.py +++ b/app/src/logging/config.py @@ -34,7 +34,7 @@ class LoggingConfig(PydanticBaseEnvConfig): human_readable_formatter: HumanReadableFormatterConfig = HumanReadableFormatterConfig() @pydantic.validator("level") - def valid_level(cls, v): # noqa: B902 + def valid_level(cls, v: str) -> str: # noqa: B902 value = logging.getLevelName(v) if not isinstance(value, int): raise ValueError("invalid logging level %s" % v) diff --git a/app/src/util/env_config.py b/app/src/util/env_config.py index 578539a5..19beabf8 100644 --- a/app/src/util/env_config.py +++ b/app/src/util/env_config.py @@ -1,4 +1,5 @@ import logging +from typing import Mapping from pydantic import BaseModel @@ -14,7 +15,7 @@ class PydanticBaseEnvConfig(BaseModel): class Config: validate_assignment = True - def override_from_environment(self, environ, prefix=""): + def override_from_environment(self, environ: Mapping[str, str], prefix: str = "") -> None: """Recursively override field values from the given environment variable mapping.""" for name, field in self.__fields__.items(): if field.is_complex(): From 1fec89a09c72a3ed2de7bfc8c28ccf4ad6cdbe1f Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 14:42:13 -0400 Subject: [PATCH 08/16] One more import order fix --- app/src/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py index 1ce442d9..af422b07 100644 --- a/app/src/config/__init__.py +++ b/app/src/config/__init__.py @@ -5,7 +5,7 @@ import importlib import logging import pathlib -from typing import Optional, Mapping +from typing import Mapping, Optional from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.app_config import AppConfig From e25d471de32faf13f3f39637fc8577ffadc4104a Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 16:14:22 -0400 Subject: [PATCH 09/16] Fix environment for database --- app/src/__main__.py | 3 --- app/src/util/env_config.py | 10 ++++++++-- docker-compose.yml | 4 ++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/__main__.py b/app/src/__main__.py index 97ca0d24..109425b7 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -21,9 +21,6 @@ def main() -> None: app = src.app.create_app(config) logger.info("loaded configuration", extra={"config": config}) - all_configs = src.config.load_all() - logger.info("loaded all", extra={"all_configs": all_configs}) - environment = config.app.environment # When running in a container, the host needs to be set to 0.0.0.0 so that the app can be diff --git a/app/src/util/env_config.py b/app/src/util/env_config.py index 19beabf8..f225effc 100644 --- a/app/src/util/env_config.py +++ b/app/src/util/env_config.py @@ -12,15 +12,20 @@ class PydanticBaseEnvConfig(BaseModel): Similar to Pydantic's BaseSettings class, but we implement our own method to override from the environment so that it can be run later, after an instance was constructed.""" + meta_overridden: list = [] + class Config: validate_assignment = True def override_from_environment(self, environ: Mapping[str, str], prefix: str = "") -> None: """Recursively override field values from the given environment variable mapping.""" for name, field in self.__fields__.items(): - if field.is_complex(): + value = getattr(self, name) + if isinstance(value, BaseModel): # Nested models must be instances of this class too. - getattr(self, name).override_from_environment(environ, prefix=name + "_") + if not isinstance(value, PydanticBaseEnvConfig): + raise TypeError("nested models must be instances of PydanticBaseEnvConfig") + value.override_from_environment(environ, prefix=name + "_") continue env_var_name = field.field_info.extra.get("env", prefix + name) @@ -28,4 +33,5 @@ def override_from_environment(self, environ: Mapping[str, str], prefix: str = "" if key in environ: # logging.debug("override from environment", extra={"key": key}) setattr(self, field.name, environ[key]) + self.meta_overridden.append((field.name, key)) break diff --git a/docker-compose.yml b/docker-compose.yml index 882a3303..be115831 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: container_name: main-db command: postgres -c "log_lock_waits=on" -N 1000 -c "fsync=off" + environment: + POSTGRES_DB: main-db + POSTGRES_USER: local_db_user + POSTGRES_PASSWORD: secret123 ports: - "5432:5432" volumes: From ca4704e6212cb00018f3d8a3847f4e6d9c3a79fa Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 16:46:33 -0400 Subject: [PATCH 10/16] Fix `make openapi-spec` --- app/Makefile | 2 +- app/src/__main__.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/Makefile b/app/Makefile index 87e1f654..3844a760 100644 --- a/app/Makefile +++ b/app/Makefile @@ -33,7 +33,7 @@ else PY_RUN_CMD := docker-compose run $(DOCKER_EXEC_ARGS) --rm $(APP_NAME) poetry run endif -FLASK_CMD := $(PY_RUN_CMD) flask --env-file local.env +FLASK_CMD := $(PY_RUN_CMD) flask --app=src.__main__:create_app ################################################## # Local Development Environment Setup diff --git a/app/src/__main__.py b/app/src/__main__.py index 109425b7..56438fec 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -8,6 +8,8 @@ import logging import os +from apiflask import APIFlask + import src.app import src.config import src.logging @@ -15,9 +17,16 @@ logger = logging.getLogger(__package__) -def main() -> None: - config = src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) +def load_config() -> src.config.RootConfig: + return src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) + +def create_app() -> APIFlask: + return src.app.create_app(load_config()) + + +def main() -> None: + config = load_config() app = src.app.create_app(config) logger.info("loaded configuration", extra={"config": config}) From d62bd78591a88f13aeb780996999919545a0a661 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Wed, 17 May 2023 16:51:05 -0400 Subject: [PATCH 11/16] Simplify `make openapi-spec` --- app/Makefile | 2 +- app/src/__main__.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/Makefile b/app/Makefile index 3844a760..bb016627 100644 --- a/app/Makefile +++ b/app/Makefile @@ -33,7 +33,7 @@ else PY_RUN_CMD := docker-compose run $(DOCKER_EXEC_ARGS) --rm $(APP_NAME) poetry run endif -FLASK_CMD := $(PY_RUN_CMD) flask --app=src.__main__:create_app +FLASK_CMD := $(PY_RUN_CMD) flask --app=src.__main__:main ################################################## # Local Development Environment Setup diff --git a/app/src/__main__.py b/app/src/__main__.py index 56438fec..c8a4176c 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -21,10 +21,6 @@ def load_config() -> src.config.RootConfig: return src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) -def create_app() -> APIFlask: - return src.app.create_app(load_config()) - - def main() -> None: config = load_config() app = src.app.create_app(config) @@ -37,6 +33,9 @@ def main() -> None: host = config.app.host port = config.app.port + if __name__ != "main": + return app + logger.info( "Running API Application", extra={"environment": environment, "host": host, "port": port} ) From f7df2b85de8f08b15772eead2a96bd1394a9b556 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Thu, 18 May 2023 10:12:52 -0400 Subject: [PATCH 12/16] Remove unused import --- app/src/__main__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/__main__.py b/app/src/__main__.py index c8a4176c..54a5c6f8 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -8,8 +8,6 @@ import logging import os -from apiflask import APIFlask - import src.app import src.config import src.logging From e56ab122fde05a3f2986592129ccc750a235b3a6 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Thu, 18 May 2023 10:15:07 -0400 Subject: [PATCH 13/16] Fix return type of main() --- app/src/__main__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/__main__.py b/app/src/__main__.py index 54a5c6f8..19edff82 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -8,6 +8,8 @@ import logging import os +from flask import Flask + import src.app import src.config import src.logging @@ -19,7 +21,7 @@ def load_config() -> src.config.RootConfig: return src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) -def main() -> None: +def main() -> Flask: config = load_config() app = src.app.create_app(config) logger.info("loaded configuration", extra={"config": config}) @@ -53,5 +55,7 @@ def main() -> None: # Don't enable the reloader if non-local app.run(host=host, port=port, load_dotenv=False) + return app + main() From 4b29c4ed5f602c2f8be1146c930b716ff99b279e Mon Sep 17 00:00:00 2001 From: James Bursa Date: Thu, 18 May 2023 10:51:31 -0400 Subject: [PATCH 14/16] Disable bandit check for app.run in debug mode --- app/src/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/__main__.py b/app/src/__main__.py index 19edff82..e2b15f9f 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -46,7 +46,7 @@ def main() -> Flask: app.run( host=host, port=port, - debug=True, + debug=True, # nosec B201 load_dotenv=False, use_reloader=True, reloader_type="stat", From d54a16e524a2ebd048cd45b56e32d712db31fdac Mon Sep 17 00:00:00 2001 From: James Bursa Date: Fri, 26 May 2023 09:09:36 -0400 Subject: [PATCH 15/16] Move load() into a separate file --- app/src/__main__.py | 7 +++-- app/src/config/__init__.py | 26 ----------------- app/src/config/load.py | 43 +++++++++++++++++++++++++++++ app/src/db/migrations/env.py | 4 +-- app/tests/conftest.py | 3 +- app/tests/src/config/test_config.py | 11 ++++---- 6 files changed, 58 insertions(+), 36 deletions(-) create mode 100644 app/src/config/load.py diff --git a/app/src/__main__.py b/app/src/__main__.py index e2b15f9f..8e21e13d 100644 --- a/app/src/__main__.py +++ b/app/src/__main__.py @@ -12,13 +12,16 @@ import src.app import src.config +import src.config.load import src.logging logger = logging.getLogger(__package__) def load_config() -> src.config.RootConfig: - return src.config.load(environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ) + return src.config.load.load( + environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ + ) def main() -> Flask: @@ -33,7 +36,7 @@ def main() -> Flask: host = config.app.host port = config.app.port - if __name__ != "main": + if __name__ != "__main__": return app logger.info( diff --git a/app/src/config/__init__.py b/app/src/config/__init__.py index af422b07..c4138c07 100644 --- a/app/src/config/__init__.py +++ b/app/src/config/__init__.py @@ -2,39 +2,13 @@ # Multi-environment configuration expressed in Python. # -import importlib -import logging -import pathlib -from typing import Mapping, Optional - from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.app_config import AppConfig from src.logging.config import LoggingConfig from src.util.env_config import PydanticBaseEnvConfig -logger = logging.getLogger(__name__) - class RootConfig(PydanticBaseEnvConfig): app: AppConfig database: PostgresDBConfig logging: LoggingConfig - - -def load(environment_name: str, environ: Optional[Mapping[str, str]] = None) -> RootConfig: - """Load the configuration for the given environment name.""" - logger.debug("loading configuration", extra={"environment": environment_name}) - module = importlib.import_module(name=".env." + environment_name, package=__package__) - config = module.config.copy(deep=True) - - if environ: - config.override_from_environment(environ) - config.app.environment = environment_name - - return config - - -def load_all() -> dict[str, RootConfig]: - """Load all environment configurations, to ensure they are valid. Used in tests.""" - directory = pathlib.Path(__file__).parent / "env" - return {item.stem: load(str(item.stem)) for item in directory.glob("*.py")} diff --git a/app/src/config/load.py b/app/src/config/load.py new file mode 100644 index 00000000..e0b1e2af --- /dev/null +++ b/app/src/config/load.py @@ -0,0 +1,43 @@ +# +# Multi-environment configuration expressed in Python. +# + +import importlib +import logging +import pathlib +from typing import Mapping, Optional + +from src.config import RootConfig + +logger = logging.getLogger(__name__) + + +def load(environment_name: str, environ: Optional[Mapping[str, str]] = None) -> RootConfig: + """Load the configuration for the given environment name.""" + logger.debug("loading configuration", extra={"environment": environment_name}) + module = importlib.import_module(name=".env." + environment_name, package=__package__) + config = module.config.copy(deep=True) + + if environment_name == "local": + # Load overrides from local_override.py in the same directory, if it exists. + try: + module = importlib.import_module(name=".env.local_override", package=__package__) + config = module.config.copy(deep=True) + except ImportError: + pass + + if environ: + config.override_from_environment(environ) + config.app.environment = environment_name + + return config + + +def load_all() -> dict[str, RootConfig]: + """Load all environment configurations, to ensure they are valid. Used in tests.""" + directory = pathlib.Path(__file__).parent / "env" + return { + item.stem: load(str(item.stem)) + for item in directory.glob("*.py") + if "override" not in item.stem + } diff --git a/app/src/db/migrations/env.py b/app/src/db/migrations/env.py index 25704c07..0e53e37f 100644 --- a/app/src/db/migrations/env.py +++ b/app/src/db/migrations/env.py @@ -13,7 +13,7 @@ from src.adapters.db.clients.postgres_client import make_connection_uri # noqa: E402 isort:skip from src.db.models import metadata # noqa: E402 isort:skip -import src.config # noqa: E402 isort:skip +import src.config.load # noqa: E402 isort:skip import src.logging # noqa: E402 isort:skip # this is the Alembic Config object, which provides @@ -22,7 +22,7 @@ logger = logging.getLogger("migrations") -root_config = src.config.load( +root_config = src.config.load.load( environment_name=os.getenv("ENVIRONMENT", "local"), environ=os.environ ) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index f663e956..2078353d 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -11,6 +11,7 @@ import src.adapters.db as db import src.app as app_entry import src.config +import src.config.load import tests.src.db.models.factories as factories from src.adapters.db.clients.postgres_config import PostgresDBConfig from src.db import models @@ -49,7 +50,7 @@ def monkeypatch_module(): def config() -> src.config.RootConfig: schema_name = f"test_schema_{uuid.uuid4().int}" - config = src.config.load("local") + config = src.config.load.load("local") config.database.db_schema = schema_name # Allow host to be overridden when running in docker-compose. diff --git a/app/tests/src/config/test_config.py b/app/tests/src/config/test_config.py index 2cfe56f5..a77d973f 100644 --- a/app/tests/src/config/test_config.py +++ b/app/tests/src/config/test_config.py @@ -6,10 +6,11 @@ import pytest from src import config +from src.config import load def test_load_with_override(): - conf = config.load( + conf = load.load( environment_name="local", environ={"app_host": "test.host", "app_port": 999, "port": 888} ) @@ -20,11 +21,11 @@ def test_load_with_override(): def test_load_with_invalid_override(): with pytest.raises(pydantic.error_wrappers.ValidationError): - config.load(environment_name="local", environ={"app_port": "not_a_number"}) + load.load(environment_name="local", environ={"app_port": "not_a_number"}) def test_load(): - conf = config.load(environment_name="local") + conf = load.load(environment_name="local") assert isinstance(conf, config.RootConfig) assert conf.app.host == "127.0.0.1" @@ -32,14 +33,14 @@ def test_load(): def test_load_invalid_environment_name(): with pytest.raises(ModuleNotFoundError): - config.load(environment_name="does_not_exist") + load.load(environment_name="does_not_exist") def test_load_all(): """This test is important to confirm that all configurations are valid - otherwise we would not know until runtime in the appropriate environment.""" - all_configs = config.load_all() + all_configs = load.load_all() # We expect at least these configs to exist - there may be others too. assert all_configs.keys() >= {"local", "dev", "prod"} From 2a01d3fa31ce64a275297e1904d160e422221318 Mon Sep 17 00:00:00 2001 From: James Bursa Date: Fri, 26 May 2023 09:27:18 -0400 Subject: [PATCH 16/16] Add local_override_example.py --- app/src/config/env/local_override_example.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/src/config/env/local_override_example.py diff --git a/app/src/config/env/local_override_example.py b/app/src/config/env/local_override_example.py new file mode 100644 index 00000000..256b2ba1 --- /dev/null +++ b/app/src/config/env/local_override_example.py @@ -0,0 +1,12 @@ +# +# Local overrides for local development environments. +# +# This file allows overrides to be set that you never want to be committed. +# +# To use this, copy to `local_override.py` and edit below. +# + +from .local import config + +# Example override: +config.logging.enable_audit = True