Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa287eba20 | ||
|
|
0f5b526df6 | ||
|
|
1d4a0a5422 | ||
|
|
69a1ba3239 |
+43
-9
@@ -77,21 +77,55 @@ A startup box with the app name, version and connect URL is printed before servi
|
||||
|
||||
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 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
|
||||
|
||||
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`
|
||||
Environment variables are used to pass values across process boundaries, where ordinary Python variables cannot be shared. We provide runtime passing mainly intended for dev environment passing into the main application CLI, as well as config passing intended for the CLI to pass things to FastAPI side.
|
||||
|
||||
The following properties read the environment and return `None` when variables haven't been set:
|
||||
Set the application prefix before `server.run()`, at top of your CLI main:
|
||||
|
||||
- `fastapi_vue.env.prefix` — the prefix itself
|
||||
- `fastapi_vue.env.dev` — running in development mode, from e.g. `MY_APP_DEV=1`
|
||||
- `fastapi_vue.env.vite_url`, `fastapi_vue.env.backend_url` — URLs set by the devserver
|
||||
```python
|
||||
os.environ["FASTAPI_VUE"] = "MY_APP"
|
||||
```
|
||||
|
||||
### Runtime environment
|
||||
|
||||
```python
|
||||
from fastapi_vue import env
|
||||
|
||||
env.prefix # application prefix
|
||||
env.dev # development mode (bool)
|
||||
env.vite_url # frontend URL (dev)
|
||||
env.backend_url # backend URL (dev)
|
||||
```
|
||||
|
||||
The three prefixed variables are set by devserver script and can be read anywhere in your application. Unset values return `None`.
|
||||
|
||||
### Teleportation
|
||||
|
||||
Mainly intended for passing application config from CLI main to all FastAPI workers and through reloader. Put shared configuration in its own module so that any part of your application can import the same `config` variable:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from fastapi_vue import env
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
project: str = "."
|
||||
read_only: bool = False
|
||||
|
||||
config = env(Config)
|
||||
```
|
||||
|
||||
The config values should be set (in CLI main) before teleportation, which occurs in `server.run()` for all registered env objects. Then everyone who imports the object receives those values. Modifications after that point however do not transit to other workers.
|
||||
|
||||
Initially the passed in dataclass or msgspec.Struct is constructed with default values to its fields. Any number of env definitions may be added for different things, each getting a prefixed env variable by type name like `MY_APP_CONFIG` above. Beside `server.run`, pass objects to your own processes with `fastapi_vue.teleport()` if needed e.g. from FastAPI app to its workers. The data format in these variables is a JSON object.
|
||||
|
||||
### Proxy configuration
|
||||
|
||||
You may set `FORWARDED_ALLOW_IPS` to specify which connecting IP addresses are trusted to provide `X-Forwarded-*` headers. This is a server setup option rather than an application setting: the devserver does not set it, and it does not use the application-name prefix. It may therefore be set globally for the whole server. The default `127.0.0.1,::1` works for typical setups where Caddy, Nginx or another frontend server runs on the same machine.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""FastAPI Vue integration - serve Vue frontend from FastAPI."""
|
||||
|
||||
from .environ import env
|
||||
from .environ import env, teleport
|
||||
from .logging import setup_logging
|
||||
from .staticfiles import Frontend
|
||||
|
||||
__all__ = ["Frontend", "env"]
|
||||
__all__ = ["Frontend", "env", "setup_logging", "teleport"]
|
||||
|
||||
@@ -4,12 +4,48 @@ The project entry point (generated __main__.py) sets FASTAPI_VUE to the
|
||||
project-specific prefix (e.g. "MY_APP"). Project settings are then passed
|
||||
as "<PREFIX>_*" environment variables; this module is the single place
|
||||
that resolves those names.
|
||||
|
||||
Call env with a dataclass or msgspec.Struct type to bind an object of
|
||||
that type, e.g. env(Config). It is decoded from "<PREFIX>_<CLASS NAME>"
|
||||
when set (in spawned server processes) and default-constructed otherwise
|
||||
(in the CLI entry point, which mutates it before server.run() calls
|
||||
teleport()).
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, TypeVar
|
||||
|
||||
PREFIX_VARIABLE = "FASTAPI_VUE"
|
||||
|
||||
# Names already used by fastapi-vue itself; bindings may not take them
|
||||
RESERVED_NAMES = frozenset({"DEV", "VITE_URL", "BACKEND_URL"})
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _mangle(name: str) -> str:
|
||||
"""Class name to env name: underscores normalized, uppercased."""
|
||||
return re.sub(r"_+", "_", name).strip("_").upper()
|
||||
|
||||
|
||||
def _encode(obj: Any) -> str: # noqa: ANN401
|
||||
if hasattr(type(obj), "__struct_fields__"):
|
||||
import msgspec # noqa: PLC0415
|
||||
|
||||
return msgspec.json.encode(obj).decode()
|
||||
return json.dumps(dataclasses.asdict(obj))
|
||||
|
||||
|
||||
def _decode(raw: str, type_: type[T]) -> T:
|
||||
if hasattr(type_, "__struct_fields__"):
|
||||
import msgspec # noqa: PLC0415
|
||||
|
||||
return msgspec.json.decode(raw, type=type_)
|
||||
return type_(**json.loads(raw))
|
||||
|
||||
|
||||
class _Env:
|
||||
"""Lazy accessors for the project's "<PREFIX>_*" environment variables.
|
||||
@@ -18,6 +54,37 @@ class _Env:
|
||||
or the variable itself is not set.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bindings: dict[str, tuple[type, Any]] = {}
|
||||
|
||||
def __call__(self, type_: type[T], *, name: str | None = None) -> T:
|
||||
"""Bind and return an object of the given type.
|
||||
|
||||
The type must be a dataclass or msgspec.Struct with defaults for
|
||||
all fields. Decoded from the "<PREFIX>_<NAME>" variable when set,
|
||||
default-constructed otherwise. The variable name is derived from
|
||||
the class name unless overridden with name=. Repeated calls with
|
||||
the same type return the same object; conflicting names raise
|
||||
KeyError.
|
||||
"""
|
||||
var = name if name is not None else _mangle(type_.__name__)
|
||||
if var in RESERVED_NAMES:
|
||||
msg = f"{var} is reserved for fastapi-vue itself"
|
||||
raise KeyError(msg)
|
||||
if bound := self._bindings.get(var):
|
||||
bound_type, obj = bound
|
||||
if bound_type is not type_:
|
||||
msg = f"{var} is already bound to {bound_type}"
|
||||
raise KeyError(msg)
|
||||
return obj
|
||||
if not (hasattr(type_, "__struct_fields__") or dataclasses.is_dataclass(type_)):
|
||||
msg = f"{type_} must be a dataclass or msgspec.Struct"
|
||||
raise TypeError(msg)
|
||||
raw = self._get(var)
|
||||
obj = _decode(raw, type_) if raw else type_()
|
||||
self._bindings[var] = (type_, obj)
|
||||
return obj
|
||||
|
||||
@property
|
||||
def prefix(self) -> str | None:
|
||||
"""Return the project prefix from the FASTAPI_VUE environment variable."""
|
||||
@@ -44,3 +111,19 @@ class _Env:
|
||||
|
||||
|
||||
env = _Env()
|
||||
|
||||
|
||||
def teleport() -> None:
|
||||
"""Serialize objects bound via env() into environment variables.
|
||||
|
||||
Called by server.run() before spawning workers, so mutations made in
|
||||
the CLI entry point propagate to them. Call directly only when spawning
|
||||
server processes by other means.
|
||||
"""
|
||||
if not env._bindings: # noqa: SLF001
|
||||
return
|
||||
if not (prefix := env.prefix):
|
||||
msg = f"{PREFIX_VARIABLE} is not set; cannot teleport bound objects"
|
||||
raise RuntimeError(msg)
|
||||
for var, (_, obj) in env._bindings.items(): # noqa: SLF001
|
||||
os.environ[f"{prefix}_{var}"] = _encode(obj)
|
||||
|
||||
@@ -98,14 +98,16 @@ class Formatter(logging.Formatter):
|
||||
use_colors: bool | None = None, # noqa: FBT001 # mirrors logging.Formatter
|
||||
*,
|
||||
access: bool = False,
|
||||
install: bool = True,
|
||||
) -> None:
|
||||
"""Load tracerite, optionally install the access log, detect color support."""
|
||||
"""Load tracerite, optionally install server patches and access log."""
|
||||
tracerite.load()
|
||||
tracerite.load_suppressions(
|
||||
extra={"starlette.routing": "until", "fastapi.routing": "until"}
|
||||
)
|
||||
patch_lifespan_logging()
|
||||
patch_server_error_middleware()
|
||||
if install:
|
||||
patch_lifespan_logging()
|
||||
patch_server_error_middleware()
|
||||
if access:
|
||||
install_access_log()
|
||||
if use_colors in (True, False):
|
||||
@@ -309,7 +311,35 @@ def _merge_log_config(base: dict, overlay: dict) -> dict:
|
||||
return base
|
||||
|
||||
|
||||
def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, ANN201
|
||||
def setup_logging(*, log_config: dict | None = None, dev: bool | None = None) -> None:
|
||||
"""Set up pretty logging standalone, outside of ``server.run()``.
|
||||
|
||||
Optional helper for CLI mains, devservers and scripts that log before
|
||||
(or without) starting the server. Loads tracerite directly and applies
|
||||
the same patching as the server path (see ``patch_log_config``, a
|
||||
private helper) with ``logging.config.dictConfig``: partial dicts merge
|
||||
over uvicorn's default config, so only customizations are needed. The
|
||||
server-side patches (error middleware, access log) are not installed —
|
||||
there is no server here. The root logger level is INFO with *dev*
|
||||
true, WARNING otherwise; *dev* of None follows ``env.dev``. An
|
||||
explicit level in *log_config* always wins::
|
||||
|
||||
import fastapi_vue
|
||||
fastapi_vue.setup_logging(log_config={"loggers": {"myapp": {"level": "DEBUG"}}})
|
||||
"""
|
||||
if log_config is not None and not isinstance(log_config, dict):
|
||||
msg = f"setup_logging requires a dict log_config, got {type(log_config).__name__}"
|
||||
raise TypeError(msg)
|
||||
import logging.config
|
||||
|
||||
tracerite.load()
|
||||
config = patch_log_config(log_config or {}, access_log=False, install=False, dev=dev)
|
||||
# Standalone setup must not disable loggers created before this call.
|
||||
config.setdefault("disable_existing_loggers", False)
|
||||
logging.config.dictConfig(config)
|
||||
|
||||
|
||||
def patch_log_config(log_config, *, access_log: bool = True, install: bool = True, dev: bool | None = None): # noqa: ANN001, ANN201
|
||||
"""Patch a uvicorn log_config dict for our logging, best-effort.
|
||||
|
||||
A dict without a ``version`` key is treated as a partial config: it is
|
||||
@@ -330,9 +360,11 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
||||
``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.
|
||||
With ``access_log``, additionally rewires the ``access`` formatter to
|
||||
our Formatter and attaches its handler to our ``fastapi_vue.access``
|
||||
logger. We must not
|
||||
With ``install=False`` (standalone use via ``setup_logging``) the
|
||||
NullHandler backdoor and the server-side patches in Formatter are
|
||||
skipped. With ``access_log``, additionally rewires the ``access``
|
||||
formatter to our Formatter and attaches its handler to our
|
||||
``fastapi_vue.access`` logger. We must not
|
||||
attach handlers to ``uvicorn.access``: uvicorn gates its own
|
||||
protocol-level access logging on ``uvicorn.access.hasHandlers()``.
|
||||
"""
|
||||
@@ -342,12 +374,13 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
||||
if "version" not in config:
|
||||
config = _merge_log_config(deepcopy(LOGGING_CONFIG), config)
|
||||
|
||||
with suppress(Exception):
|
||||
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
|
||||
config["handlers"]["fastapi_vue"] = {
|
||||
"class": "logging.NullHandler",
|
||||
"formatter": "fastapi_vue",
|
||||
}
|
||||
if install:
|
||||
with suppress(Exception):
|
||||
config["formatters"]["fastapi_vue"] = {"()": "fastapi_vue.logging.Formatter"}
|
||||
config["handlers"]["fastapi_vue"] = {
|
||||
"class": "logging.NullHandler",
|
||||
"formatter": "fastapi_vue",
|
||||
}
|
||||
|
||||
with suppress(Exception):
|
||||
filters = config.setdefault("filters", {})
|
||||
@@ -367,6 +400,7 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
||||
"()": "fastapi_vue.logging.Formatter",
|
||||
"fmt": "%(message)s",
|
||||
"use_colors": None,
|
||||
"install": install,
|
||||
}
|
||||
|
||||
# uvicorn's default config leaves the root logger handlerless, eating
|
||||
@@ -375,7 +409,7 @@ def patch_log_config(log_config, *, access_log: bool = True): # noqa: ANN001, A
|
||||
# with Python's default; dev keeps INFO. Subloggers can override.
|
||||
with suppress(Exception):
|
||||
root = config.setdefault("root", {})
|
||||
root.setdefault("level", "INFO" if env.dev else "WARNING")
|
||||
root.setdefault("level", "INFO" if (env.dev if dev is None else dev) else "WARNING")
|
||||
if "default" in config.get("handlers", {}):
|
||||
root_handlers = root.setdefault("handlers", [])
|
||||
if "default" not in root_handlers:
|
||||
|
||||
@@ -15,7 +15,7 @@ from uvicorn import Config, Server
|
||||
from uvicorn.main import STARTUP_FAILURE
|
||||
from uvicorn.supervisors import ChangeReload, Multiprocess
|
||||
|
||||
from .environ import env
|
||||
from .environ import env, teleport
|
||||
from .hostutil import parse_endpoints
|
||||
from .logging import (
|
||||
install_access_log,
|
||||
@@ -148,6 +148,8 @@ def run( # noqa: PLR0913
|
||||
msg = "No endpoints to serve; check listen configuration"
|
||||
raise ValueError(msg)
|
||||
|
||||
teleport() # Serialize bound objects before spawning workers
|
||||
|
||||
if startup_box:
|
||||
print_startup_box(startup_box, app, endpoints)
|
||||
|
||||
|
||||
@@ -23,3 +23,8 @@ build-backend = "hatchling.build"
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
raw-options.root = ".."
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"msgspec>=0.21.1",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for env() object binding and teleport()."""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
from fastapi_vue import env, teleport
|
||||
from fastapi_vue.environ import PREFIX_VARIABLE, _Env
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_bindings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
env._bindings.clear() # noqa: SLF001
|
||||
monkeypatch.setenv(PREFIX_VARIABLE, "TEST")
|
||||
yield
|
||||
env._bindings.clear() # noqa: SLF001
|
||||
|
||||
|
||||
class StructConfig(msgspec.Struct):
|
||||
"""msgspec struct config."""
|
||||
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DataclassConfig:
|
||||
"""Dataclass config."""
|
||||
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
|
||||
|
||||
def test_default_construction_and_identity() -> None:
|
||||
"""Unset env: default-constructed; repeated binds return the same object."""
|
||||
config = env(StructConfig)
|
||||
assert config.host == "localhost"
|
||||
assert env(StructConfig) is config
|
||||
|
||||
|
||||
def test_name_mangling() -> None:
|
||||
"""Class names uppercase as-is; repeated/edge underscores normalize."""
|
||||
env(StructConfig)
|
||||
teleport()
|
||||
assert json.loads(os.environ["TEST_STRUCTCONFIG"])["port"] == 8000
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _my__config_: # noqa: N801
|
||||
x: int = 0
|
||||
|
||||
env(_my__config_)
|
||||
teleport()
|
||||
assert "TEST_MY_CONFIG" in os.environ
|
||||
|
||||
|
||||
def test_name_override() -> None:
|
||||
"""name= overrides the mangled class name verbatim."""
|
||||
env(DataclassConfig, name="SETTINGS")
|
||||
teleport()
|
||||
assert "TEST_SETTINGS" in os.environ
|
||||
|
||||
|
||||
def test_name_conflict() -> None:
|
||||
"""A different type with a colliding env name raises KeyError."""
|
||||
env(StructConfig)
|
||||
with pytest.raises(KeyError, match="already bound"):
|
||||
env(DataclassConfig, name="STRUCTCONFIG")
|
||||
|
||||
|
||||
def test_reserved_names() -> None:
|
||||
"""Names used by fastapi-vue itself cannot be bound, by mangle or name=."""
|
||||
with pytest.raises(KeyError, match="reserved"):
|
||||
env(DataclassConfig, name="DEV")
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Dev:
|
||||
x: int = 0
|
||||
|
||||
with pytest.raises(KeyError, match="reserved"):
|
||||
env(Dev)
|
||||
|
||||
|
||||
def test_unsupported_type() -> None:
|
||||
"""Only dataclasses and msgspec.Structs can be bound."""
|
||||
with pytest.raises(TypeError, match=r"dataclass or msgspec\.Struct"):
|
||||
env(dict)
|
||||
|
||||
|
||||
def test_struct_teleport_and_decode() -> None:
|
||||
"""A mutated struct teleports; a fresh registry decodes it (worker view)."""
|
||||
config = env(StructConfig)
|
||||
config.port = 9000
|
||||
teleport()
|
||||
decoded = _Env()(StructConfig)
|
||||
assert decoded == config
|
||||
assert decoded is not config
|
||||
|
||||
|
||||
def test_dataclass_teleport_and_decode() -> None:
|
||||
"""Dataclasses round-trip through the environment via stdlib json."""
|
||||
config = env(DataclassConfig)
|
||||
config.host = "example.com"
|
||||
teleport()
|
||||
assert _Env()(DataclassConfig) == config
|
||||
|
||||
|
||||
def test_decode_on_bind_when_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An already-set variable is decoded at binding time."""
|
||||
monkeypatch.setenv("TEST_STRUCTCONFIG", '{"host": "example.com", "port": 1}')
|
||||
assert env(StructConfig).host == "example.com"
|
||||
|
||||
|
||||
def test_teleport_without_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""teleport() with bindings but no FASTAPI_VUE prefix fails loudly."""
|
||||
monkeypatch.delenv(PREFIX_VARIABLE)
|
||||
env(StructConfig)
|
||||
with pytest.raises(RuntimeError, match=PREFIX_VARIABLE):
|
||||
teleport()
|
||||
|
||||
|
||||
def test_teleport_noop_without_bindings() -> None:
|
||||
"""No bindings: teleport() needs no prefix and does nothing."""
|
||||
teleport()
|
||||
@@ -10,21 +10,31 @@ from pathlib import Path
|
||||
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
# Duplicated from fastapi_vue.logging because build environment is isolated
|
||||
_LEVEL_EMOJI = {
|
||||
logging.DEBUG: "🐛",
|
||||
logging.INFO: "🔷",
|
||||
logging.WARNING: "❗",
|
||||
logging.ERROR: "🛑",
|
||||
logging.CRITICAL: "🚨",
|
||||
}
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Emoji level prefix formatter, mirroring fastapi_vue.logging.Formatter."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
emoji = _LEVEL_EMOJI.get(record.levelno)
|
||||
prefix = f"{emoji} " if emoji else f"{record.levelname}: "
|
||||
return prefix + record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user