From fed8b1395fa29e00e253d09e3dd6c5550a2ef0e8 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 26 Sep 2026 18:13:33 -0700 Subject: [PATCH] Respect configured root log level and make SQLAlchemy echo configurable LoggingHandler.install() forced the root logger to DEBUG, and the ORM engine hard-coded echo=True, flooding logs with SQL and aiosqlite debug output. The root level now follows logging.level (LOG_LEVEL, default info) and the engine echo follows the connection's echo option (DB_ECHO, default false). Co-Authored-By: Claude Opus 5.5 --- example/inertia-pingcrm-app/.env.example | 3 ++ .../logging/config/logging.py | 1 + .../src/fastapi_startkit/logging/handler.py | 8 +++- .../logging/managers/LoggingManager.py | 7 +-- .../logging/providers/log_provider.py | 2 +- .../masoniteorm/config/config.py | 4 ++ .../masoniteorm/connections/factory.py | 2 +- .../tests/logging/test_log_level.py | 48 +++++++++++++++++++ .../tests/masoniteorm/config/test_db_url.py | 15 ++++++ 9 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 fastapi_startkit/tests/logging/test_log_level.py diff --git a/example/inertia-pingcrm-app/.env.example b/example/inertia-pingcrm-app/.env.example index 34169464..c34665c2 100644 --- a/example/inertia-pingcrm-app/.env.example +++ b/example/inertia-pingcrm-app/.env.example @@ -3,12 +3,15 @@ APP_ENV=local APP_URL=http://localhost:8000 APP_DEBUG=true +LOG_LEVEL=info + DB_CONNECTION=postgres DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=database_app_test DB_USERNAME=app DB_PASSWORD=secret +DB_ECHO=false AWS_ENDPOINT=http://localhost:9000 AWS_ACCESS_KEY_ID=minioadmin diff --git a/fastapi_startkit/src/fastapi_startkit/logging/config/logging.py b/fastapi_startkit/src/fastapi_startkit/logging/config/logging.py index 553a7426..6c9aa923 100644 --- a/fastapi_startkit/src/fastapi_startkit/logging/config/logging.py +++ b/fastapi_startkit/src/fastapi_startkit/logging/config/logging.py @@ -7,6 +7,7 @@ @dataclasses.dataclass class LoggingConfig: default: str = dataclasses.field(default_factory=lambda: env("LOG_CHANNEL", "stack")) + level: str = dataclasses.field(default_factory=lambda: env("LOG_LEVEL", "info")) channels: dict = dataclasses.field( default_factory=lambda: { diff --git a/fastapi_startkit/src/fastapi_startkit/logging/handler.py b/fastapi_startkit/src/fastapi_startkit/logging/handler.py index c6ee368e..9298bf78 100644 --- a/fastapi_startkit/src/fastapi_startkit/logging/handler.py +++ b/fastapi_startkit/src/fastapi_startkit/logging/handler.py @@ -1,5 +1,8 @@ import logging +# Framework-only severities have no stdlib equivalent; map them to the nearest one. +_STDLIB_LEVELS = {"notice": "INFO", "alert": "CRITICAL", "emergency": "CRITICAL"} + class LoggingHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: @@ -8,8 +11,9 @@ def emit(self, record: logging.LogRecord) -> None: Logger.log(record.levelname, self.format(record)) @staticmethod - def install(): + def install(level: str | None = None): root_logger = logging.getLogger() if not any(isinstance(h, LoggingHandler) for h in root_logger.handlers): root_logger.addHandler(LoggingHandler()) - root_logger.setLevel(logging.DEBUG) + if level: + root_logger.setLevel(_STDLIB_LEVELS.get(level.lower(), level.upper())) diff --git a/fastapi_startkit/src/fastapi_startkit/logging/managers/LoggingManager.py b/fastapi_startkit/src/fastapi_startkit/logging/managers/LoggingManager.py index ef862726..c646cd30 100644 --- a/fastapi_startkit/src/fastapi_startkit/logging/managers/LoggingManager.py +++ b/fastapi_startkit/src/fastapi_startkit/logging/managers/LoggingManager.py @@ -7,10 +7,11 @@ def __init__( self, channel_factory: type[ChannelFactory] = ChannelFactory, driver_factory: type[DriverFactory] = DriverFactory, + level: str | None = None, ): self.channel_factory = channel_factory self.driver_factory = driver_factory - self.configure_python_logging() + self.configure_python_logging(level) def channel(self, channel: str): channel_class = self.channel_factory.make(channel) @@ -19,7 +20,7 @@ def channel(self, channel: str): return channel_class() @classmethod - def configure_python_logging(cls): + def configure_python_logging(cls, level: str | None = None): from ..handler import LoggingHandler - LoggingHandler.install() + LoggingHandler.install(level) diff --git a/fastapi_startkit/src/fastapi_startkit/logging/providers/log_provider.py b/fastapi_startkit/src/fastapi_startkit/logging/providers/log_provider.py index e65a8838..92cf65e9 100644 --- a/fastapi_startkit/src/fastapi_startkit/logging/providers/log_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/logging/providers/log_provider.py @@ -17,7 +17,7 @@ def register(self): self.app.bind("LogChannelFactory", ChannelFactory) self.app.bind("LogDriverFactory", DriverFactory) - self.app.bind("LoggingManager", LoggingManager(ChannelFactory, DriverFactory)) + self.app.bind("LoggingManager", LoggingManager(ChannelFactory, DriverFactory, config.get("level"))) def boot(self): self.publishes({Path(__file__).resolve().parent.parent.joinpath("config/logging.py"): "config/logging.py"}) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/config.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/config.py index e196a942..18e76af2 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/config.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/config.py @@ -1,5 +1,6 @@ from typing import Optional, Dict, Any from pydantic.dataclasses import dataclass +from pydantic.fields import Field from fastapi_startkit.environment.environment import env @@ -8,6 +9,7 @@ class SQLiteConfig: driver: str = "sqlite" url: Optional[str] = env("DB_URL", None, cast=False) database: str = env("DB_DATABASE", "database.sqlite") + echo: bool = Field(default_factory=lambda: env("DB_ECHO", False)) options: Optional[Dict[str, Any]] = None @@ -23,6 +25,7 @@ class MySQLConfig: unix_socket: str = env("DB_SOCKET", "", cast=False) charset: str = env("DB_CHARSET", "utf8mb4") collation: str = env("DB_COLLATION", "utf8mb4_unicode_ci") + echo: bool = Field(default_factory=lambda: env("DB_ECHO", False)) options: Optional[Dict[str, Any]] = None @@ -37,4 +40,5 @@ class PostgresConfig: password: str = env("DB_PASSWORD", "", cast=False) charset: str = env("DB_CHARSET", "utf8") sslmode: str = env("DB_SSLMODE", "prefer") + echo: bool = Field(default_factory=lambda: env("DB_ECHO", False)) options: Optional[Dict[str, Any]] = None diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py index b038475e..bbaab65d 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py @@ -64,7 +64,7 @@ def build_url(cls, config: dict) -> str: @classmethod def create_engine(cls, cfg: dict) -> AsyncEngine: url = cls.build_url(cfg) - kwargs: dict[str, Any] = {"echo": True} + kwargs: dict[str, Any] = {"echo": bool(cfg.get("echo", False))} from fastapi_startkit.application import app if cast("Application", app()).is_testing(): diff --git a/fastapi_startkit/tests/logging/test_log_level.py b/fastapi_startkit/tests/logging/test_log_level.py new file mode 100644 index 00000000..e5223bdc --- /dev/null +++ b/fastapi_startkit/tests/logging/test_log_level.py @@ -0,0 +1,48 @@ +import logging +import unittest + +from fastapi_startkit.logging.config import LoggingConfig +from fastapi_startkit.logging.handler import LoggingHandler +from fastapi_startkit.logging.managers import LoggingManager + + +class RootLogLevelTest(unittest.TestCase): + def setUp(self): + self.root = logging.getLogger() + self._handlers = list(self.root.handlers) + self._level = self.root.level + + def tearDown(self): + for handler in list(self.root.handlers): + if handler not in self._handlers: + self.root.removeHandler(handler) + self.root.setLevel(self._level) + + def test_install_without_level_leaves_root_level_untouched(self): + self.root.setLevel(logging.WARNING) + LoggingHandler.install() + self.assertEqual(self.root.level, logging.WARNING) + + def test_manager_applies_configured_info_level(self): + self.root.setLevel(logging.WARNING) + LoggingManager(level="info") + self.assertEqual(self.root.level, logging.INFO) + + def test_configured_level_is_applied_even_when_handler_already_installed(self): + LoggingHandler.install("debug") + LoggingHandler.install("error") + self.assertEqual(self.root.level, logging.ERROR) + self.assertEqual(len([h for h in self.root.handlers if isinstance(h, LoggingHandler)]), 1) + + def test_framework_only_levels_map_to_stdlib_levels(self): + LoggingHandler.install("notice") + self.assertEqual(self.root.level, logging.INFO) + LoggingHandler.install("emergency") + self.assertEqual(self.root.level, logging.CRITICAL) + + def test_logging_config_defaults_to_info(self): + self.assertEqual(LoggingConfig().level, "info") + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/config/test_db_url.py b/fastapi_startkit/tests/masoniteorm/config/test_db_url.py index 56de4253..b5477c26 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_db_url.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_db_url.py @@ -1,6 +1,7 @@ import unittest from fastapi_startkit.exceptions.exceptions import DriverNotFound +from fastapi_startkit.masoniteorm.config.config import MySQLConfig, PostgresConfig, SQLiteConfig from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory @@ -175,3 +176,17 @@ def test_url_passthrough_bypasses_driver_validation(self): } url = ConnectionFactory.build_url(config) self.assertEqual(url, "sqlite+aiosqlite:///db.sqlite3") + + +class TestConnectionFactoryEcho(unittest.TestCase): + def test_engine_echo_is_off_by_default(self): + engine = ConnectionFactory.create_engine({"driver": "sqlite", "database": ":memory:"}) + self.assertFalse(engine.echo) + + def test_engine_echo_follows_config(self): + engine = ConnectionFactory.create_engine({"driver": "sqlite", "database": ":memory:", "echo": True}) + self.assertTrue(engine.echo) + + def test_connection_configs_default_echo_to_false(self): + for config_class in (SQLiteConfig, MySQLConfig, PostgresConfig): + self.assertFalse(config_class().echo)