Treat dict log_config without a version key as an overlay on uvicorn defaults.

A partial log_config (no "version" key) is deep-merged over uvicorn's
default config before our patching, so users pass only their
customizations, e.g. log_config={"loggers": {"myapp": {"level":
"DEBUG"}}}.  Previously such dicts crashed at startup: dictConfig
requires a version, and our root entry referenced a "default" handler
that might not exist; that reference is now only added when the handler
exists.  The stock default formatter is replaced only when untouched,
so an overlaid fmt survives.  Dicts with version and non-dict configs
behave as before.  Documented in the README logging section, with tests
under fastapi-vue/tests (each patched config validated through
dictConfig).
This commit is contained in:
2026-09-16 03:57:44 +00:00
parent 5730e5dd01
commit d1e82b5955
5 changed files with 91 additions and 17 deletions
+3 -6
View File
@@ -75,19 +75,16 @@ A startup box with the app name, version and connect URL is printed before servi
<img src="https://raw.githubusercontent.com/LeoVasanko/fastapi-vue-setup/main/docs/my-app.webp" alt="My App startup box and log items" width="500">
Logging is integrated as well: removes noisy uvicorn logging, replacing it with prettified log formatting, a colored access log and tracebacks rendered by [tracerite](https://pypi.org/project/tracerite/). Note that HTTP responses also include tracerite formatting when `FastAPI(debug=True)` is used.
Other arguments are generally passed to `uvicorn.run`, although some like `log_config` receive our modifications.
> As a deployment option, environment `FORWARDED_ALLOW_IPS` controls `X-Forwarded` trusted IPs (default: `127.0.0.1,::1` works for typical setups).
### Logging
As a framework we configure basic logging for you: the root logger prints through our emoji-level-prefixed formatting, at INFO in dev mode and WARNING in production — so library chatter stays silent in production, matching Python's own default. On top of that we only add narrow overrides: uvicorn's routine chatter is filtered out, `watchfiles.main` is lifted to WARNING (reload notices still show), and our access log gets its own colored format.
### Logging and exceptions
Your code and libraries just use ordinary loggers (`logging.getLogger(...)`); they inherit the root level automatically. If a module wants a different level than that — quieter *or* more verbose — set it on that module's own logger, not on root.
Pretty logging is configured automatically across the host process and all workers, at INFO in development and WARNING in production, with emoji level prefixes, colored access logs, and tracebacks rendered by [tracerite](https://pypi.org/project/tracerite/). With `FastAPI(debug=True)`, **Internal Server Error** responses use tracerite formatting as well.
Unlike stock FastAPI/uvicorn, where the root logger is left handlerless (swallowing `logging.info()` from app code) and uvicorn logs startup/shutdown chatter at INFO, here app logging just works and the output stays terse.
Application code can simply use `logging.info()` through `logging.exception()`, or ordinary `logging.getLogger("myapp")` loggers, without setting up logging itself. Set any logger's level when part of the application should be quieter or more verbose, for example `log_config={"loggers": {"myapp": {"level": "DEBUG"}}}`, accepting additions and overrides using [Python's logging configuration schema](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema).
### Environment (fastapi_vue.env)
+26 -11
View File
@@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Literal
import tracerite
from starlette.middleware.errors import ServerErrorMiddleware
from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
from uvicorn.config import Config
from uvicorn.config import LOGGING_CONFIG, Config
from uvicorn.lifespan.on import LifespanOn
if TYPE_CHECKING:
@@ -299,13 +299,25 @@ def patch_server_error_middleware() -> None:
ServerErrorMiddleware.error_response = error_response # type: ignore[method-assign]
def _merge_log_config(base: dict, overlay: dict) -> dict:
"""Deep-merge *overlay* onto *base*; dicts merge recursively, others replace."""
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_merge_log_config(base[key], value)
else:
base[key] = value
return base
def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, ANN201
"""Patch a uvicorn log_config dict for our logging, best-effort.
Users presumably base their config on uvicorn's default dict, but any
shape is tolerated: pieces that do not fit the config's structure are
silently skipped. Non-dict configs (e.g. an ini file path) pass through
untouched.
A dict without a ``version`` key is treated as a partial config: it is
merged over uvicorn's default dict, so only the customizations are
needed (e.g. ``{"loggers": {"kanta": {"level": "DEBUG"}}}``). A dict
with ``version`` is a complete config used as-is; pieces that do not
fit its structure are silently skipped. Non-dict configs (e.g. an ini
file path) pass through untouched.
Always adds an unreferenced NullHandler whose Formatter instantiation
loads tracerite in every process uvicorn applies the config in, filters
@@ -313,8 +325,8 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
routine INFO lines, an emoji-level-prefix Formatter in place of
uvicorn's stock ``default`` formatter (a user-supplied one wins), a root
logger entry so ``logging.info()`` et al. print through the default
handler, at INFO in dev and WARNING in production (matching Python's
default). The
handler when one exists, at INFO in dev and WARNING in production
(matching Python's default). The
``watchfiles.main`` logger is lifted to WARNING so its INFO "N changes
detected" line is dropped while the WARNING "Reloading..." line (logged
to ``uvicorn.error``) still shows; a user-supplied level wins.
@@ -327,6 +339,8 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
if not isinstance(log_config, dict):
return log_config
config = deepcopy(log_config)
if "version" not in config:
config = _merge_log_config(deepcopy(LOGGING_CONFIG), config)
with suppress(Exception):
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
@@ -348,7 +362,7 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
# default formatter; a user-supplied default formatter is left alone.
with suppress(Exception):
default = config["formatters"]["default"]
if default.get("()") in (None, "uvicorn.logging.DefaultFormatter"):
if default == LOGGING_CONFIG["formatters"]["default"]:
config["formatters"]["default"] = {
"()": "fastapi_vue.logging.Formatter",
"fmt": "%(message)s",
@@ -362,9 +376,10 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
with suppress(Exception):
root = config.setdefault("root", {})
root.setdefault("level", "INFO" if env.dev else "WARNING")
root_handlers = root.setdefault("handlers", [])
if "default" not in root_handlers:
root_handlers.append("default")
if "default" in config.get("handlers", {}):
root_handlers = root.setdefault("handlers", [])
if "default" not in root_handlers:
root_handlers.append("default")
# watchfiles logs "N changes detected" to its own logger at INFO; only the
# WARNING "Reloading..." line (uvicorn.error) should show.
+1
View File
@@ -0,0 +1 @@
"""Tests for the fastapi_vue package."""
+60
View File
@@ -0,0 +1,60 @@
"""Tests for fastapi_vue.logging.patch_log_config overlay behavior."""
import logging.config
from fastapi_vue.logging import patch_log_config
def test_empty_dict_overlays_uvicorn_defaults() -> None:
"""An empty dict is a partial config: merged over uvicorn's defaults."""
config = patch_log_config({})
assert config["version"] == 1
assert config["disable_existing_loggers"] is False
assert config["root"]["handlers"] == ["default"]
assert "uvicorn" in config["loggers"]
logging.config.dictConfig(config) # must be a valid, complete config
def test_partial_logger_customization() -> None:
"""The documented use case: only the customization, no boilerplate."""
config = patch_log_config({"loggers": {"kanta": {"level": "DEBUG"}}})
assert config["loggers"]["kanta"] == {"level": "DEBUG"}
assert config["loggers"]["uvicorn"]["handlers"] == ["default"]
assert config["loggers"]["watchfiles.main"]["level"] == "WARNING"
logging.config.dictConfig(config)
assert logging.getLogger("kanta").level == logging.DEBUG
def test_overlay_root_level_wins() -> None:
"""User-supplied root level is kept; our handler wiring still applies."""
config = patch_log_config({"root": {"level": "ERROR"}})
assert config["root"]["level"] == "ERROR"
assert config["root"]["handlers"] == ["default"]
logging.config.dictConfig(config)
assert logging.getLogger().level == logging.ERROR
def test_overlay_formatter_customization_keeps_stock_siblings() -> None:
"""A user formatter replaces ours; the access formatter still works."""
config = patch_log_config({"formatters": {"default": {"fmt": "%(name)s %(message)s"}}})
assert config["formatters"]["default"] == {
"()": "uvicorn.logging.DefaultFormatter", # stock class, user's fmt
"fmt": "%(name)s %(message)s",
"use_colors": None,
}
assert "access" in config["formatters"]
logging.config.dictConfig(config)
def test_full_config_used_as_is() -> None:
"""A dict with version is complete: no uvicorn loggers appear."""
config = patch_log_config({"version": 1})
assert "uvicorn" not in config.get("loggers", {})
# No "default" handler exists, so root must not reference one.
assert "handlers" not in config["root"]
logging.config.dictConfig(config)
def test_non_dict_passes_through() -> None:
"""Non-dict configs (e.g. an ini file path) are returned untouched."""
assert patch_log_config("logging.ini") == "logging.ini"