Compare commits

..
3 Commits
Author SHA1 Message Date
LeoVasanko d1e82b5955 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).
2026-09-16 03:57:44 +00:00
LeoVasanko 5730e5dd01 Document logging principles under the server section. 2026-09-16 02:52:46 +00:00
LeoVasanko 3a9c5d1674 Set root logger level by dev mode, drop kanta logging config.
The root logger entry patch_log_config adds now uses INFO in dev and
WARNING in production (Python's default), so third-party library INFO
noise stays silent in production while subloggers remain free to
define their own level overrides.  A user-supplied root level still
wins (setdefault).

staticfiles now logs via its own module logger instead of
uvicorn.error: the startup stats line shows in dev (root INFO) and is
hidden in production, and it no longer passes through the
uvicorn-quiet filter that silently ate it.

The kanta logger/handler/formatter block is removed: kanta configures
its own event loggers at import time as of its logging rework, and its
diagnostics follow the root logger like any other library.
2026-09-16 02:22:35 +00:00
6 changed files with 100 additions and 31 deletions
+7 -2
View File
@@ -75,12 +75,17 @@ 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"> <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. 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). > As a deployment option, environment `FORWARDED_ALLOW_IPS` controls `X-Forwarded` trusted IPs (default: `127.0.0.1,::1` works for typical setups).
### Logging and exceptions
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.
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) ### Environment (fastapi_vue.env)
We use environment variables to pass values between program components, from devserver script setting dev mode and telling backend and frontend URLs, to your CLI, which in turn runs the FastAPI app that may also need access to this information. The variables are prefixed by the current application name to avoid conflicts. The CLI entry point should set one like `os.environ["FASTAPI_VUE"] = "MY_APP"`, before using `server.run` We use environment variables to pass values between program components, from devserver script setting dev mode and telling backend and frontend URLs, to your CLI, which in turn runs the FastAPI app that may also need access to this information. The variables are prefixed by the current application name to avoid conflicts. The CLI entry point should set one like `os.environ["FASTAPI_VUE"] = "MY_APP"`, before using `server.run`
+30 -28
View File
@@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Literal
import tracerite import tracerite
from starlette.middleware.errors import ServerErrorMiddleware from starlette.middleware.errors import ServerErrorMiddleware
from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response 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 from uvicorn.lifespan.on import LifespanOn
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
from uvicorn.lifespan.on import LifespanSendMessage from uvicorn.lifespan.on import LifespanSendMessage
from .accesslog import AccessLogMiddleware from .accesslog import AccessLogMiddleware
from .environ import env
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
@@ -298,13 +299,25 @@ def patch_server_error_middleware() -> None:
ServerErrorMiddleware.error_response = error_response # type: ignore[method-assign] 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 def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, ANN201
"""Patch a uvicorn log_config dict for our logging, best-effort. """Patch a uvicorn log_config dict for our logging, best-effort.
Users presumably base their config on uvicorn's default dict, but any A dict without a ``version`` key is treated as a partial config: it is
shape is tolerated: pieces that do not fit the config's structure are merged over uvicorn's default dict, so only the customizations are
silently skipped. Non-dict configs (e.g. an ini file path) pass through needed (e.g. ``{"loggers": {"kanta": {"level": "DEBUG"}}}``). A dict
untouched. 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 Always adds an unreferenced NullHandler whose Formatter instantiation
loads tracerite in every process uvicorn applies the config in, filters loads tracerite in every process uvicorn applies the config in, filters
@@ -312,7 +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 routine INFO lines, an emoji-level-prefix Formatter in place of
uvicorn's stock ``default`` formatter (a user-supplied one wins), a root uvicorn's stock ``default`` formatter (a user-supplied one wins), a root
logger entry so ``logging.info()`` et al. print through the default logger entry so ``logging.info()`` et al. print through the default
handler, and a no-prefix ``kanta`` logger entry (likewise). 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 ``watchfiles.main`` logger is lifted to WARNING so its INFO "N changes
detected" line is dropped while the WARNING "Reloading..." line (logged detected" line is dropped while the WARNING "Reloading..." line (logged
to ``uvicorn.error``) still shows; a user-supplied level wins. to ``uvicorn.error``) still shows; a user-supplied level wins.
@@ -325,6 +339,8 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
if not isinstance(log_config, dict): if not isinstance(log_config, dict):
return log_config return log_config
config = deepcopy(log_config) config = deepcopy(log_config)
if "version" not in config:
config = _merge_log_config(deepcopy(LOGGING_CONFIG), config)
with suppress(Exception): with suppress(Exception):
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"} config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
@@ -346,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. # default formatter; a user-supplied default formatter is left alone.
with suppress(Exception): with suppress(Exception):
default = config["formatters"]["default"] default = config["formatters"]["default"]
if default.get("()") in (None, "uvicorn.logging.DefaultFormatter"): if default == LOGGING_CONFIG["formatters"]["default"]:
config["formatters"]["default"] = { config["formatters"]["default"] = {
"()": "fastapi_vue.logging.Formatter", "()": "fastapi_vue.logging.Formatter",
"fmt": "%(message)s", "fmt": "%(message)s",
@@ -355,12 +371,15 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
# uvicorn's default config leaves the root logger handlerless, eating # uvicorn's default config leaves the root logger handlerless, eating
# logging.info() et al.; route root through uvicorn's default handler. # logging.info() et al.; route root through uvicorn's default handler.
# Level is WARNING in production so third-party loggers stay quiet, as
# with Python's default; dev keeps INFO. Subloggers can override.
with suppress(Exception): with suppress(Exception):
root = config.setdefault("root", {}) root = config.setdefault("root", {})
root.setdefault("level", "INFO") root.setdefault("level", "INFO" if env.dev else "WARNING")
root_handlers = root.setdefault("handlers", []) if "default" in config.get("handlers", {}):
if "default" not in root_handlers: root_handlers = root.setdefault("handlers", [])
root_handlers.append("default") if "default" not in root_handlers:
root_handlers.append("default")
# watchfiles logs "N changes detected" to its own logger at INFO; only the # watchfiles logs "N changes detected" to its own logger at INFO; only the
# WARNING "Reloading..." line (uvicorn.error) should show. # WARNING "Reloading..." line (uvicorn.error) should show.
@@ -369,23 +388,6 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
"level", "WARNING" "level", "WARNING"
) )
# kanta-style output (diffs, colored headers) prints without prefixes,
# like our access log. A user-supplied "kanta" logger entry wins.
with suppress(Exception):
config["formatters"].setdefault("plain", {"fmt": "%(message)s"})
config["handlers"].setdefault(
"plain",
{
"class": "logging.StreamHandler",
"formatter": "plain",
"stream": "ext://sys.stderr",
},
)
config.setdefault("loggers", {}).setdefault(
"kanta",
{"handlers": ["plain"], "level": "INFO", "propagate": False},
)
if access_log: if access_log:
with suppress(Exception): with suppress(Exception):
config["formatters"]["access"] = { config["formatters"]["access"] = {
+1 -1
View File
@@ -21,7 +21,7 @@ from zstandard import ZstdCompressor
from .environ import env from .environ import env
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style logger = logging.getLogger(__name__)
__all__ = ["Frontend"] __all__ = ["Frontend"]
+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"
+1
View File
@@ -49,3 +49,4 @@ ignore = ["CPY", "D203", "D213", "COM812", "PLR2004"]
"template/**" = ["F821"] # Undefined names are template placeholders "template/**" = ["F821"] # Undefined names are template placeholders
"template/scripts/devserver.py" = ["N806"] # MODULE_NAME is a template variable "template/scripts/devserver.py" = ["N806"] # MODULE_NAME is a template variable
"fastapi_vue_setup.py" = ["PLR", "C901", "T201", "RUF001"] "fastapi_vue_setup.py" = ["PLR", "C901", "T201", "RUF001"]
"**/tests/**" = ["S101"] # Asserts are the point of tests