Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e82b5955 | ||
|
|
5730e5dd01 | ||
|
|
3a9c5d1674 |
@@ -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`
|
||||||
|
|||||||
@@ -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"] = {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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/**" = ["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
|
||||||
|
|||||||
Reference in New Issue
Block a user