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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the fastapi_vue package."""
|
||||
@@ -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"
|
||||
@@ -49,3 +49,4 @@ ignore = ["CPY", "D203", "D213", "COM812", "PLR2004"]
|
||||
"template/**" = ["F821"] # Undefined names are template placeholders
|
||||
"template/scripts/devserver.py" = ["N806"] # MODULE_NAME is a template variable
|
||||
"fastapi_vue_setup.py" = ["PLR", "C901", "T201", "RUF001"]
|
||||
"**/tests/**" = ["S101"] # Asserts are the point of tests
|
||||
|
||||
Reference in New Issue
Block a user