diff --git a/docs/controllers.md b/docs/controllers.md index f250b3e..f889a9d 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -14,7 +14,9 @@ be the first two parameters. from asgikit.requests import Request, read_json from asgikit.responses import respond_text, respond_redirect from selva.web import controller, get, post -from loguru import logger +import structlog + +logger = structlog.get_logger() @controller @@ -28,7 +30,7 @@ class IndexController: class AdminController: @post("send") async def handle_data(self, request: Request): - logger.info(await read_json(request)) + logger.info("request body", content=str(await read_json(request))) await respond_redirect(request.response, "/") ``` @@ -41,8 +43,7 @@ handler with the annotation `FromPath`: ```python from typing import Annotated -from selva.web.converter import FromPath -from selva.web.routing.decorator import get +from selva.web import get, FromPath @get("/:path_param") diff --git a/docs/logging.md b/docs/logging.md index 98f2ad8..48afba0 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,54 +1,57 @@ # Logging -Selva uses [loguru](https://pypi.org/project/loguru/) for logging, but provides +Selva uses [Structlog](https://www.structlog.org) for logging and provides some facilities on top of it to make its usage a bit closer to other frameworks like Spring Boot. -First, an interceptor to the standard `logging` module is configured by default, -as suggested in . +It is integrated with the standard library logging, so libraries that use it are logged +through Structlog. It also enables filtering by logger name using the standard library. -Second, a custom logging filter is provided in order to set the logging level for -each package independently. +## Why? -## Configuring logging +Nowadays, it is very likely that your application is deployed to a cloud and its +logs are sent to an aggregator like Graylog, so a structured logging format seems +to be the logical choice. + +For more information on why use structured logging, refer to the +[Structlog documentation](https://www.structlog.org/en/stable/why.html). + +## Configure logging Logging is configured in the Selva configuration: ```yaml logging: - root: WARNING - level: + root: WARNING # (1) + level: # (2) application: INFO - application.service: TRACE - sqlalchemy: DEBUG - enable: - - packages_to_activate_logging - disabled: - - packages_to_deactivate_logging + selva: DEBUG + format: json # (3) + setup: selva.logging.setup # (4) ``` -The `root` property is the *root* level. It is used if no other level is set for the -package where the log comes from. +1. Log level of the root logger. +2. Mapping of logger names to log level. +3. Log format. Possible values are `"json"`, `"logfmt"`, `"keyvalue""` and `"console"`. +4. Setup function to configure logging. -The `level` property defines the logging level for each package independently. +The `format` config defines which renderer will be used. The possible values map to: -The `enable` and `disable` properties lists the packages to enable or disable logging. -This comes from loguru, as can be seen in . +| value | renderer | +|----------|----------------------------------------------------------| +| `json` | `structlog.processors.JSONRenderer()` | +| `logfmt` | `structlog.processors.LogfmtRenderer(bool_as_flag=True)` | +| `keyvalue` | `structlog.processors.KeyValueRenderer()` | +| `console` | `structlog.dev.ConsoleRenderer()` | -## Manual logger setup +If not defined, `format` defaults to `"json"` if `sys.stderr.isatty() == False`, +or `"console"` otherwise. This is done to use the `ConsoleRenderer` during development +and the `JSONRenderer` when deploying to production. -If you want full control of how loguru is configured, you can provide a logger setup -function and reference it in the configuration file: +## Manual logger setup -=== "application/logging.py" - - ```python - from loguru import logger - - - def setup(settings): - logger.configure(...) - ``` +If you need full control of how Structlog is configured, you can provide a logger setup +function. You just need to reference it in the configuration file: === "configuration/settings.yaml" @@ -57,5 +60,16 @@ function and reference it in the configuration file: setup: application.logging.setup ``` +=== "application/logging.py" + + ```python + import structlog + from selva.configuration import Settings + + + def setup(settings: Settings): + structlog.configure(...) + ``` + The setup function receives a parameter of type `selva.configuration.Settings`, so you can have access to the whole settings. diff --git a/docs/middleware.md b/docs/middleware.md index 461eda0..a6ab758 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -30,9 +30,11 @@ in the processing of the request: from datetime import datetime from asgikit.requests import Request + import structlog from selva.di import service from selva.web.middleware import Middleware, CallNext - from loguru import logger + + logger = structlog.get_logger() @service @@ -43,7 +45,7 @@ in the processing of the request: request_end = datetime.now() delta = request_end - request_start - logger.info("Request time: {}", delta) + logger.info("request duration", duration=str(delta)) ``` 1. Invoke the middleware chain to process the request diff --git a/examples/background_tasks/application.py b/examples/background_tasks/application.py index 2696149..f1a9022 100644 --- a/examples/background_tasks/application.py +++ b/examples/background_tasks/application.py @@ -2,10 +2,12 @@ from asgikit.requests import Request from asgikit.responses import respond_json -from loguru import logger +import structlog from selva.web import controller, get +logger = structlog.get_logger() + @controller class Controller: diff --git a/examples/database/application/__init__.py b/examples/database/application/__init__.py index b81ab33..e69de29 100644 --- a/examples/database/application/__init__.py +++ b/examples/database/application/__init__.py @@ -1,9 +0,0 @@ -import sys - -from loguru import logger - -logger.remove() -logger.enable("selva") -logger.disable("databases") -logger.disable("aiosqlite") -logger.add(sys.stderr, level="DEBUG") diff --git a/examples/database/application/service.py b/examples/database/application/service.py index 0d65654..964809b 100644 --- a/examples/database/application/service.py +++ b/examples/database/application/service.py @@ -2,21 +2,23 @@ from typing import Annotated from databases import Database -from loguru import logger +import structlog from selva.configuration import Settings from selva.di import Inject, service +logger = structlog.get_logger() + @service def database_factory(settings: Settings) -> Database: database = Database(settings.database.url) - logger.info("Sqlite database created") + logger.info("sqlite database created") yield database os.unlink(database.url.database) - logger.info("Sqlite database destroyed") + logger.info("sqlite database destroyed") @service @@ -25,7 +27,7 @@ class Repository: async def initialize(self): await self.database.connect() - logger.info("Sqlite database connection opened") + logger.info("sqlite database connection opened") await self.database.execute( "create table if not exists counter(value int not null)" @@ -34,7 +36,7 @@ async def initialize(self): async def finalize(self): await self.database.disconnect() - logger.info("Sqlite database connection closed") + logger.info("sqlite database connection closed") async def test(self): await self.database.execute("select 1") @@ -42,5 +44,5 @@ async def test(self): async def count(self) -> int: await self.database.execute("update counter set value = value + 1") result = await self.database.fetch_val("select value from counter") - logger.info("Current count: {}", result) + logger.info("count updated", count=result) return result diff --git a/examples/hello_world/application.py b/examples/hello_world/application.py index 3bdfbf2..8f549d8 100644 --- a/examples/hello_world/application.py +++ b/examples/hello_world/application.py @@ -2,12 +2,15 @@ from asgikit.requests import Request, read_json from asgikit.responses import respond_json -from loguru import logger from pydantic import BaseModel +import structlog + from selva.di import Inject, service from selva.web import FromPath, FromQuery, controller, get, post +logger = structlog.get_logger() + class MyModel(BaseModel): name: str @@ -32,7 +35,7 @@ async def greet_query( number: Annotated[int, FromQuery] = 1, ): greeting = self.greeter.greet(name) - logger.info(greeting) + logger.info(greeting, name=name, number=number) await respond_json(request.response, {"greeting": greeting, "number": number}) @get("/:name") diff --git a/examples/middleware/application/middleware.py b/examples/middleware/application/middleware.py index 8f19600..33a1799 100644 --- a/examples/middleware/application/middleware.py +++ b/examples/middleware/application/middleware.py @@ -4,10 +4,13 @@ from asgikit.requests import Request from asgikit.responses import respond_status -from loguru import logger + +import structlog from selva.web.middleware import Middleware +logger = structlog.get_logger() + class TimingMiddleware(Middleware): async def __call__(self, chain, request: Request): @@ -20,7 +23,7 @@ async def __call__(self, chain, request: Request): request_end = datetime.now() delta = request_end - request_start - logger.info("Request time: {}", delta) + logger.info("request duration", duration=str(delta)) class LoggingMiddleware(Middleware): @@ -35,7 +38,7 @@ async def __call__(self, chain, request: Request): request_line = f"{request.method} {request.path} HTTP/{request.http_version}" status = request.response.status - logger.info('{} "{}" {} {}', client, request_line, status.value, status.phrase) + logger.info("request", client=client, request_line=request_line, status=status.value, status_phrase=status.phrase) class AuthMiddleware(Middleware): @@ -52,7 +55,7 @@ async def __call__(self, chain, request: Request): authn = authn.removeprefix("Basic") user, password = base64.urlsafe_b64decode(authn).decode().split(":") - logger.info("User '{}' with password '{}'", user, password) + logger.info("user logged in", user=user, password=password) request["user"] = user await chain(request) diff --git a/examples/middleware/application/service.py b/examples/middleware/application/service.py index d231ff3..e475cd7 100644 --- a/examples/middleware/application/service.py +++ b/examples/middleware/application/service.py @@ -6,23 +6,23 @@ DEFAULT_NAME = "World" -class Settings(NamedTuple): +class Config(NamedTuple): default_name: str @service -def settings_factory() -> Settings: +def settings_factory() -> Config: default_name = os.getenv("DEFAULT_NAME", DEFAULT_NAME) - return Settings(default_name) + return Config(default_name) @service class Greeter: - settings: Annotated[Settings, Inject] + config: Annotated[Config, Inject] @property def default_name(self): - return self.settings.default_name + return self.config.default_name def greet(self, name: str = None): greeted_name = name or self.default_name diff --git a/examples/settings/configuration/settings.yaml b/examples/settings/configuration/settings.yaml index c4908c7..2213d4e 100644 --- a/examples/settings/configuration/settings.yaml +++ b/examples/settings/configuration/settings.yaml @@ -1 +1,6 @@ -message: ${MESSAGE:Hello, World!} \ No newline at end of file +message: ${MESSAGE:Hello, World!} + +logging: + root: info + level: + selva.web: debug \ No newline at end of file diff --git a/examples/sqlalchemy/application/model.py b/examples/sqlalchemy/application/model.py index a9c0f7e..431c981 100644 --- a/examples/sqlalchemy/application/model.py +++ b/examples/sqlalchemy/application/model.py @@ -8,7 +8,7 @@ class Base(DeclarativeBase): class MyModel(Base): __tablename__ = 'my_model' - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(length=100)) def __repr__(self): @@ -21,7 +21,7 @@ class OtherBase(DeclarativeBase): class OtherModel(OtherBase): __tablename__ = 'my_model' - id: Mapped[int] = Column(primary_key=True, autoincrement=True) + id: Mapped[int] = Column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = Column(String(length=100)) def __repr__(self): diff --git a/examples/websocket/application.py b/examples/websocket/application.py index 2453181..5b637ca 100644 --- a/examples/websocket/application.py +++ b/examples/websocket/application.py @@ -5,13 +5,15 @@ from asgikit.requests import Request from asgikit.responses import respond_file from asgikit.websockets import WebSocket -from loguru import logger +import structlog from selva.configuration import Settings from selva.di import Inject, service from selva.web import controller, get, websocket from selva.web.exception import WebSocketException +logger = structlog.get_logger() + @service class WebSocketService: @@ -23,17 +25,15 @@ async def handle_websocket(self, request: Request): ws = request.websocket self.clients[client] = ws - logger.info("client connected: {}", client) + logger.info("client connected", client=repr(client)) while True: try: message = await ws.receive() - logger.info( - "client message: content={}, client={}", message, repr(client) - ) + logger.info("client message", content=message, client=repr(client)) await self.broadcast(message) except (WebSocketDisconnectError, WebSocketException): - logger.info("client disconnected: {}", repr(client)) + logger.info("client disconnected", client=repr(client)) del self.clients[client] break @@ -46,7 +46,7 @@ async def broadcast(self, message: str): await ws.send(message) except (WebSocketDisconnectError, WebSocketException): del self.clients[client] - logger.info("client disconnected: {}", repr(client)) + logger.info("client disconnected", client=repr(client)) @controller diff --git a/pyproject.toml b/pyproject.toml index e160bc7..a18bbf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,9 @@ packages = [ python = "^3.11" asgikit = "^0.8" pydantic = "^2.7" -loguru = "^0.7" python-dotenv = "^1.0" "ruamel.yaml" = "^0.18" +structlog = "^24.1.0" jinja2 = { version = "^3.1", optional = true } SQLAlchemy = { version = "^2.0", optional = true } redis = { version = "^5.0", optional = true } diff --git a/src/selva/configuration/defaults.py b/src/selva/configuration/defaults.py index 346cea6..6115627 100644 --- a/src/selva/configuration/defaults.py +++ b/src/selva/configuration/defaults.py @@ -3,11 +3,7 @@ "extensions": [], "middleware": [], "logging": { - "setup": "selva.logging.setup.setup_logger", - "root": "WARNING", - "level": {}, - "enable": [], - "disable": [], + "setup": "selva.logging.setup", }, "templates": { "backend": None, diff --git a/src/selva/configuration/settings.py b/src/selva/configuration/settings.py index e0a4b9e..faa0b85 100644 --- a/src/selva/configuration/settings.py +++ b/src/selva/configuration/settings.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from loguru import logger +import structlog from ruamel.yaml import YAML from selva.configuration.defaults import default_settings @@ -17,6 +17,8 @@ __all__ = ("Settings", "SettingsError", "get_settings") +logger = structlog.get_logger(__name__) + SETTINGS_DIR_ENV = "SELVA_SETTINGS_DIR" SETTINGS_FILE_ENV = "SELVA_SETTINGS_FILE" @@ -28,11 +30,18 @@ class Settings(Mapping[str, Any]): def __init__(self, data: dict): + self._original_data = copy.deepcopy(data) + self.__data = data + for key, value in data.items(): if isinstance(value, dict): data[key] = Settings(value) - self.__data = data + def __getattr__(self, item: str): + try: + return self.__data[item] + except KeyError: + raise AttributeError(item) def __len__(self) -> int: return len(self.__data) @@ -46,24 +55,18 @@ def __contains__(self, key: str): def __getitem__(self, key: str): return self.__data[key] - def __getattr__(self, item: str): - try: - return self.__data[item] - except KeyError: - raise AttributeError(item) - def __copy__(self): - return Settings(copy.copy(self.__data)) + return Settings(copy.copy(self._original_data)) def __deepcopy__(self, memodict): - data = copy.deepcopy(self.__data, memodict) + data = copy.deepcopy(self._original_data, memodict) return Settings(data) def __eq__(self, other: Any) -> bool: if isinstance(other, Settings): - return self.__data == other.__data + return self._original_data == other._original_data if isinstance(other, Mapping): - return self.__data == other + return self._original_data == other return False @@ -81,31 +84,39 @@ def __init__(self, path: Path): @cache -def get_settings() -> Settings: +def get_settings() -> tuple[Settings, list[tuple[Path, bool]]]: return _get_settings_nocache() -def _get_settings_nocache() -> Settings: +def _get_settings_nocache() -> tuple[Settings, list[tuple[Path, bool]]]: # get default settings settings = deepcopy(default_settings) + paths = [] # merge with main settings file (settings.yaml) - merge_recursive(settings, get_settings_for_profile()) + profile_settings, path = get_settings_for_profile() + merge_recursive(settings, profile_settings) + paths.append(path) # merge with profile settings files (settings_$SELVA_PROFILE.yaml) if active_profile_list := os.getenv(SELVA_PROFILE): - for active_profile in (p.strip() for p in active_profile_list.split(",")): - merge_recursive(settings, get_settings_for_profile(active_profile)) + for active_profile in active_profile_list.split(","): + active_profile = active_profile.strip() + profile_settings, path = get_settings_for_profile(active_profile) + merge_recursive(settings, profile_settings) + paths.append(path) # merge with environment variables (SELVA_*) from_env_vars = parse_settings_from_env(os.environ) merge_recursive(settings, from_env_vars) settings = replace_variables_recursive(settings, os.environ) - return Settings(settings) + return Settings(settings), paths -def get_settings_for_profile(profile: str = None) -> dict[str, Any]: +def get_settings_for_profile( + profile: str = None, +) -> tuple[dict[str, Any], tuple[Path, bool]]: settings_file = os.getenv(SETTINGS_FILE_ENV, DEFAULT_SETTINGS_FILE) settings_dir_path = Path(os.getenv(SETTINGS_DIR_ENV, DEFAULT_SETTINGS_DIR)) settings_file_path = settings_dir_path / settings_file @@ -118,18 +129,18 @@ def get_settings_for_profile(profile: str = None) -> dict[str, Any]: settings_file_path = settings_file_path.absolute() try: - logger.info("settings loaded from {}", settings_file_path) + # logger.info("settings loaded", file=str(settings_file_path)) yaml = YAML(typ="safe") - return yaml.load(settings_file_path) or {} + return yaml.load(settings_file_path) or {}, (settings_file_path, True) except FileNotFoundError: - if profile: - logger.warning( - "no settings file found for profile '{}' at {}", - profile, - settings_file_path, - ) - - return {} + # if profile: + # logger.warning( + # "settings file not found", + # profile=profile, + # file=str(settings_file_path), + # ) + + return {}, (settings_file_path, False) except Exception as err: raise SettingsError(settings_file_path) from err diff --git a/src/selva/di/container.py b/src/selva/di/container.py index 8340b3b..f83660a 100644 --- a/src/selva/di/container.py +++ b/src/selva/di/container.py @@ -4,7 +4,7 @@ from types import FunctionType, ModuleType from typing import Any, Type, TypeVar -from loguru import logger +import structlog from selva._util.maybe_async import maybe_async from selva._util.package_scan import scan_packages @@ -18,9 +18,11 @@ ) from selva.di.interceptor import Interceptor from selva.di.service.model import InjectableType, ServiceDependency, ServiceSpec -from selva.di.service.parse import get_dependencies, parse_service_spec +from selva.di.service.parse import parse_service_spec from selva.di.service.registry import ServiceRegistry +logger = structlog.get_logger(__name__) + T = TypeVar("T") @@ -50,29 +52,29 @@ def register(self, injectable: InjectableType): if startup: self.startup.append((service_spec.provides, name)) + log_context = { + "service": f"{injectable.__module__}.{injectable.__qualname__}", + } + + if name: + log_context["name"] = name + if provides: - logger.trace( - "service registered: {}.{}; provides={}.{} name={}", - injectable.__module__, - injectable.__qualname__, - provides.__module__, - provides.__qualname__, - name or "", - ) - else: - logger.trace( - "service registered: {}.{}; name={}", - injectable.__module__, - injectable.__qualname__, - name or "", - ) + log_context["provides"] = f"{provides.__module__}.{provides.__qualname__}" + + logger.debug("service registered", **log_context) def define(self, service_type: type, instance: Any, *, name: str = None): self.store[service_type, name] = instance - logger.trace( - "service defined: {}; name={}", service_type.__qualname__, name or "" - ) + log_context = { + "service": f"{service_type.__module__}.{service_type.__qualname__}" + } + + if name: + log_context["name"] = name + + logger.debug("service defined", **log_context) def interceptor(self, interceptor: Type[Interceptor]): self.register( @@ -84,7 +86,10 @@ def interceptor(self, interceptor: Type[Interceptor]): ) self.interceptors.append(interceptor) - logger.trace("interceptor registered: {}", interceptor.__qualname__) + logger.debug( + "interceptor registered", + interceptor=f"{interceptor.__module__}.{interceptor.__qualname__}", + ) def scan(self, *packages: str | ModuleType): def predicate_services(item: Any): diff --git a/src/selva/di/service/parse.py b/src/selva/di/service/parse.py index 843411d..9f64d63 100644 --- a/src/selva/di/service/parse.py +++ b/src/selva/di/service/parse.py @@ -4,7 +4,7 @@ from types import NoneType, UnionType from typing import Annotated, Any, Optional, TypeVar, Union -from loguru import logger +import structlog from selva.di.error import ( FactoryMissingReturnTypeError, @@ -18,6 +18,8 @@ DI_INITIALIZER = "initialize" DI_FINALIZER = "finalize" +logger = structlog.get_logger() + def _check_optional(type_hint: type, default: Any) -> tuple[type, bool]: is_optional = default is None @@ -123,7 +125,10 @@ def parse_service_spec( factory = None elif inspect.isfunction(injectable): if provides: - logger.warning("option 'provides' on a factory function has no effect") + logger.warning( + "option 'provides' on a factory function has no effect", + factory=f"{injectable.__module__}.{injectable.__name__}", + ) provided_service = _parse_definition_factory(injectable) initializer = None diff --git a/src/selva/ext/data/sqlalchemy/service.py b/src/selva/ext/data/sqlalchemy/service.py index 4a2748b..387aa37 100644 --- a/src/selva/ext/data/sqlalchemy/service.py +++ b/src/selva/ext/data/sqlalchemy/service.py @@ -1,12 +1,14 @@ -from loguru import logger +import structlog from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from selva.configuration.settings import Settings -from selva.di import Container, Inject +from selva.di import Container from selva.ext.data.sqlalchemy.settings import SqlAlchemyEngineSettings from .settings import SqlAlchemySettings +logger = structlog.get_logger() + def make_engine_service(name: str): async def engine_service( @@ -61,6 +63,6 @@ async def sessionmaker_service( engine = engines.get("default") if not engine: name, engine = next(iter(engines.items())) - logger.warning("Using connection '{}' for sqlalchemy session", name) + logger.warning("connection for sqlalchemy session", connection=name) return async_sessionmaker(engine, **options) diff --git a/src/selva/logging.py b/src/selva/logging.py new file mode 100644 index 0000000..092ead3 --- /dev/null +++ b/src/selva/logging.py @@ -0,0 +1,71 @@ +import logging +import logging.config +import sys + +import structlog + +from selva.configuration.settings import Settings + + +def setup(settings: Settings): + structlog.configure( + processors=[structlog.stdlib.ProcessorFormatter.wrap_for_formatter], + logger_factory=structlog.stdlib.LoggerFactory(), + ) + + log_format = settings.logging.get("format") + if not log_format: + log_format = "console" if sys.stderr.isatty() else "json" + + if log_format == "json": + renderer = structlog.processors.JSONRenderer() + elif log_format == "logfmt": + renderer = structlog.processors.LogfmtRenderer(bool_as_flag=False) + elif log_format == "keyvalue": + renderer = structlog.processors.KeyValueRenderer() + elif log_format == "console": + renderer = structlog.dev.ConsoleRenderer() + else: + raise ValueError("Unknown log format") + + processors = [ + structlog.stdlib.filter_by_level, + structlog.contextvars.merge_contextvars, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.add_logger_name, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + ] + + if not isinstance(renderer, structlog.dev.ConsoleRenderer): + processors.append(structlog.processors.dict_tracebacks) + + processors.append(renderer) + + logging_config = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "structlog": { + "()": structlog.stdlib.ProcessorFormatter, + "processors": processors, + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "structlog", + } + }, + "root": { + "handlers": ["console"], + "level": settings.logging.get("root", "INFO").upper(), + }, + "loggers": { + module: {"level": level.upper()} + for module, level in settings.logging.get("level", {}).items() + }, + } + + logging.config.dictConfig(logging_config) diff --git a/src/selva/logging/__init__.py b/src/selva/logging/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/selva/logging/logfmt.py b/src/selva/logging/logfmt.py deleted file mode 100644 index 0da7f11..0000000 --- a/src/selva/logging/logfmt.py +++ /dev/null @@ -1,10 +0,0 @@ -def logfmt_format(record): - message = { - "time": f"{record['time']}", - "level": f"{record['level']}", - "source": f"{record['module']}.{record['name']}:{record['function']}:{record['line']}", - "message": '"' + record["message"].replace('"', r"\"") + '"', - } - message |= record["extra"] - - return " ".join(f"{key}={value}" for key, value in message.items()) + "\n" diff --git a/src/selva/logging/setup.py b/src/selva/logging/setup.py deleted file mode 100644 index c494d86..0000000 --- a/src/selva/logging/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -import sys -from functools import cache - -from loguru import logger - -from selva.configuration.settings import Settings -from selva.logging.stdlib import setup_loguru_std_logging_interceptor - - -def setup_logger(settings: Settings): - enable = [(name, True) for name in settings.logging.enable] - disable = [(name, False) for name in settings.logging.disable] - - # enabling has precedence over disabling - # therefore the "enable" list comes after the "disable" list - activation = disable + enable - - log_config = settings.get("logging", {}) - root_level = logger.level(log_config.get("root", "WARNING")) - log_level = { - name: logger.level(value) for name, value in log_config.get("level", {}).items() - } - - filter_func = filter_func_factory(root_level, log_level) - handler = {"sink": sys.stderr, "filter": filter_func} - - logger.configure(handlers=[handler], activation=activation) - - setup_loguru_std_logging_interceptor() - - -def filter_func_factory(root_level, log_level: dict): - @cache - def has_level(name: str, record_level): - level = log_level.get(name) - - while not level: - match name.rsplit(".", 1): - case [first, _last]: - name = first - level = log_level.get(name) - case _: - level = root_level - - return record_level.no >= level.no - - def filter_func(record): - name = record["name"] - record_level = record["level"] - return has_level(name, record_level) - - return filter_func diff --git a/src/selva/logging/stdlib.py b/src/selva/logging/stdlib.py deleted file mode 100644 index efeeb55..0000000 --- a/src/selva/logging/stdlib.py +++ /dev/null @@ -1,28 +0,0 @@ -import inspect -import logging - -from loguru import logger - - -class InterceptHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - # Get corresponding Loguru level if it exists. - level: str | int - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - # Find caller from where originated the logged message. - frame, depth = inspect.currentframe(), 0 - while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log( - level, record.getMessage() - ) - - -def setup_loguru_std_logging_interceptor(): - logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) diff --git a/src/selva/run.py b/src/selva/run.py index b498c9b..5976234 100644 --- a/src/selva/run.py +++ b/src/selva/run.py @@ -1,7 +1,6 @@ from selva._util import dotenv -from selva.configuration.settings import get_settings from selva.web.application import Selva dotenv.init() -settings = get_settings() -app = Selva(settings) + +app = Selva() diff --git a/src/selva/web/application.py b/src/selva/web/application.py index 6ac7b44..2507af4 100644 --- a/src/selva/web/application.py +++ b/src/selva/web/application.py @@ -4,18 +4,17 @@ import typing from http import HTTPStatus from typing import Any -from uuid import uuid4 +import structlog from asgikit.errors.websocket import WebSocketDisconnectError, WebSocketError from asgikit.requests import Request from asgikit.responses import respond_status, respond_text from asgikit.websockets import WebSocket -from loguru import logger from selva._util.base_types import get_base_types from selva._util.import_item import import_item from selva._util.maybe_async import maybe_async -from selva.configuration.settings import Settings +from selva.configuration.settings import Settings, get_settings from selva.di.container import Container from selva.web.converter import ( from_request_impl, @@ -36,6 +35,8 @@ from selva.web.routing.decorator import CONTROLLER_ATTRIBUTE from selva.web.routing.router import Router +logger = structlog.get_logger() + def _is_controller(arg) -> bool: return inspect.isclass(arg) and hasattr(arg, CONTROLLER_ATTRIBUTE) @@ -45,6 +46,24 @@ def _is_module(arg) -> bool: return inspect.ismodule(arg) or isinstance(arg, str) +def _init_settings(settings: Settings | None) -> Settings: + paths = [] + if not settings: + settings, paths = get_settings() + + logging_setup = import_item(settings.logging.setup) + logging_setup(settings) + + for path, found in paths: + path = str(path) + if found: + logger.info("settings loaded", settings_file=path) + else: + logger.warning("settings file not found", settings_file=path) + + return settings + + class Selva: """Entrypoint class for a Selva Application @@ -52,28 +71,25 @@ class Selva: Other modules and classes can be registered using the "register" method """ - def __init__(self, settings: Settings): + def __init__(self, settings: Settings = None): + self.settings = _init_settings(settings) + self.di = Container() self.di.define(Container, self.di) + self.di.define(Settings, self.settings) + self.router = Router() self.di.define(Router, self.router) - self.settings = settings - self.di.define(Settings, self.settings) - self.handler = self._process_request self._register_modules() - setup_logger = import_item(self.settings.logging.setup) - setup_logger(self.settings) - async def __call__(self, scope, receive, send): match scope["type"]: case "http" | "websocket": - with logger.contextualize(request_id=uuid4()): - await self._handle_request(scope, receive, send) + await self._handle_request(scope, receive, send) case "lifespan": await self._handle_lifespan(scope, receive, send) case _: @@ -124,7 +140,13 @@ async def _initialize_middleware(self): f"Middleware classes must be of type '{mid_class_name}': {mid_classes}" ) + import selva.di.decorator + for cls in reversed(middleware): + if not hasattr(cls, selva.di.decorator.DI_ATTRIBUTE_SERVICE): + selva.di.decorator.service(cls) + self.di.register(cls) + mid = await self.di.get(cls) chain = functools.partial(mid, self.handler) self.handler = chain @@ -141,22 +163,22 @@ async def _handle_lifespan(self, _scope, receive, send): while True: message = await receive() if message["type"] == "lifespan.startup": - logger.trace("Handling lifespan startup") + logger.debug("handling lifespan startup") try: await self._lifespan_startup() - logger.trace("Lifespan startup complete") + logger.debug("lifespan startup complete") await send({"type": "lifespan.startup.complete"}) except Exception as err: - logger.trace("Lifespan startup failed") + logger.exception("lifespan startup failed") await send({"type": "lifespan.startup.failed", "message": str(err)}) elif message["type"] == "lifespan.shutdown": - logger.trace("Handling lifespan shutdown") + logger.debug("handling lifespan shutdown") try: await self._lifespan_shutdown() - logger.trace("Lifespan shutdown complete") + logger.debug("lifespan shutdown complete") await send({"type": "lifespan.shutdown.complete"}) except Exception as err: - logger.trace("Lifespan shutdown failed") + logger.debug("lifespan shutdown failed") await send( {"type": "lifespan.shutdown.failed", "message": str(err)} ) @@ -165,13 +187,6 @@ async def _handle_lifespan(self, _scope, receive, send): async def _handle_request(self, scope, receive, send): request = Request(scope, receive, send) - logger.trace( - "Started handling of request '{} {} {}'", - request.method, - request.path, - request.raw_query, - ) - try: try: await self.handler(request) @@ -180,21 +195,21 @@ async def _handle_request(self, scope, receive, send): ExceptionHandler[type(err)], optional=True ): logger.debug( - "Handling exception with handler {}.{}", - handler.__class__.__module__, - handler.__class__.__qualname__, + "Handling exception with handler", + module=handler.__class__.__module__, + handler=handler.__class__.__qualname__, ) await handler.handle_exception(request, err) else: raise except (WebSocketDisconnectError, WebSocketError): - logger.exception("WebSocket error") + logger.exception("websocket error") await request.websocket.close() except WebSocketException as err: await request.websocket.close(err.code, err.reason) except HTTPException as err: if websocket := request.websocket: - logger.exception("WebSocket request raised HTTPException") + logger.exception("websocket request raised HTTPException") await websocket.close() return @@ -207,12 +222,12 @@ async def _handle_request(self, scope, receive, send): stack_trace = None if response.is_started: - logger.error("Response has already started") + logger.error("response has already started") await response.end() return if response.is_finished: - logger.error("Response is finished") + logger.error("response is finished") return if stack_trace: @@ -220,7 +235,7 @@ async def _handle_request(self, scope, receive, send): else: await respond_status(response, status=err.status) except Exception: - logger.exception("Error processing request") + logger.exception("error processing request") await respond_text( request.response, @@ -229,6 +244,13 @@ async def _handle_request(self, scope, receive, send): ) async def _process_request(self, request: Request): + logger.debug( + "handling request", + method=str(request.method), + path=request.path, + query=request.raw_query, + ) + method = request.method path = request.path diff --git a/src/selva/web/middleware.py b/src/selva/web/middleware/__init__.py similarity index 100% rename from src/selva/web/middleware.py rename to src/selva/web/middleware/__init__.py diff --git a/src/selva/web/middleware/request_id.py b/src/selva/web/middleware/request_id.py new file mode 100644 index 0000000..a91839b --- /dev/null +++ b/src/selva/web/middleware/request_id.py @@ -0,0 +1,19 @@ +import uuid + +import structlog +from asgikit.requests import Request + +from selva.web.middleware import CallNext, Middleware + + +class RequestIdMiddleware(Middleware): + async def __call__( + self, + call_next: CallNext, + request: Request, + ): + request_id = request.headers.get("X-Request-Id", str(uuid.uuid4())) + request["request_id"] = request_id + structlog.contextvars.bind_contextvars(request_id=request_id) + await call_next(request) + structlog.contextvars.unbind_contextvars("request_id") diff --git a/src/selva/web/routing/router.py b/src/selva/web/routing/router.py index 490d0d2..1321649 100644 --- a/src/selva/web/routing/router.py +++ b/src/selva/web/routing/router.py @@ -2,7 +2,7 @@ from collections import OrderedDict from http import HTTPMethod -from loguru import logger +import structlog from selva.web.exception import HTTPNotFoundException from selva.web.routing.decorator import ( @@ -17,6 +17,8 @@ ) from selva.web.routing.route import Route, RouteMatch +logger = structlog.get_logger() + def _path_with_prefix(path: str, prefix: str): path = path.strip("/") @@ -38,11 +40,10 @@ def route(self, controller: type): path_prefix = controller_info.path - logger.trace( - "controller registered at {}: {}.{}", - path_prefix or "/", - controller.__module__, - controller.__qualname__, + logger.debug( + "controller registered", + path_prefix=path_prefix or "/", + controller=f"{controller.__module__}.{controller.__qualname__}", ) for name, action in inspect.getmembers(controller, inspect.isfunction): @@ -67,13 +68,12 @@ def route(self, controller: type): raise DuplicateRouteError(route.name, current_route.name) self.routes[route_name] = route - logger.trace( - "action '{}.{}:{}' registered at '{} {}'", - controller.__module__, - controller.__qualname__, - route.action.__name__, - route.method, - route.path, + logger.debug( + "action registered", + controller=f"{controller.__module__}.{controller.__qualname__}", + action=route.action.__name__, + method=route.method, + path=route.path, ) def match(self, method: HTTPMethod | None, path: str) -> RouteMatch | None: diff --git a/tests/configuration/test_settings.py b/tests/configuration/test_settings.py index b768870..c3f70d3 100644 --- a/tests/configuration/test_settings.py +++ b/tests/configuration/test_settings.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from structlog.testing import capture_logs from selva.configuration.defaults import default_settings from selva.configuration.settings import ( @@ -16,7 +17,7 @@ def test_get_settings(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "base") - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "prop": "value", "list": ["1", "2", "3"], @@ -38,7 +39,7 @@ def test_get_settings_with_profile(monkeypatch, profile): monkeypatch.chdir(Path(__file__).parent / "profiles") monkeypatch.setenv("SELVA_PROFILE", profile) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "name": "application", "profile": profile, @@ -53,7 +54,7 @@ def test_get_settings_with_multiple_profiles(monkeypatch, profile_a, profile_b): monkeypatch.chdir(Path(__file__).parent / "multiple_profiles") monkeypatch.setenv("SELVA_PROFILE", f"{profile_a}, {profile_b}") - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { f"profile_{profile_a}": True, f"profile_{profile_b}": True, @@ -73,14 +74,14 @@ def test_get_settings_with_multiple_profiles(monkeypatch, profile_a, profile_b): def test_get_settings_for_profile(monkeypatch, profile, expected): monkeypatch.chdir(Path(__file__).parent / "profiles") - result = get_settings_for_profile(profile) + result, _ = get_settings_for_profile(profile) assert result == expected def test_empty_settings_file(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "empty") - result = get_settings_for_profile() + result, _ = get_settings_for_profile() assert result == {} @@ -90,7 +91,7 @@ def test_configure_settings_dir(monkeypatch): str(Path(__file__).parent / "base" / "configuration"), ) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "prop": "value", "list": ["1", "2", "3"], @@ -111,7 +112,7 @@ def test_configure_settings_file(monkeypatch): "application.yaml", ) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "prop": "value", "list": ["1", "2", "3"], @@ -135,7 +136,7 @@ def test_configure_settings_dir_and_file(monkeypatch): "application.yaml", ) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "prop": "value", "list": ["1", "2", "3"], @@ -158,7 +159,7 @@ def test_configure_settings_file_with_profile(monkeypatch): monkeypatch.setenv("SELVA_PROFILE", "prd") - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "profile": "prd", "prop": "value", @@ -182,7 +183,7 @@ def test_configure_env_setttings(monkeypatch, env): ) monkeypatch.setenv("SELVA_PROFILE", env) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | { "name": "application", "profile": env, @@ -196,19 +197,19 @@ def test_configure_env_setttings(monkeypatch, env): def test_override_settings(monkeypatch, env): monkeypatch.chdir(Path(__file__).parent / "override") - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | {"value": "base"} monkeypatch.setenv("SELVA_PROFILE", env) - result = _get_settings_nocache() + result, _ = _get_settings_nocache() assert result == default_settings | {"value": env} def test_settings_class(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "base") - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings["prop"] == "value" assert settings["list"] == ["1", "2", "3"] assert settings["dict"] == { @@ -226,12 +227,12 @@ def test_setttings_class_env(monkeypatch, env): monkeypatch.chdir(Path(__file__).parent / "profiles") monkeypatch.setenv("SELVA_PROFILE", env) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings["name"] == "application" assert settings["profile"] == env -def test_no_profile_settings_file_should_log_warning(monkeypatch, caplog): +def test_no_profile_settings_file_should_log_warning(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "profiles") profile = "does_not_exist" @@ -239,20 +240,16 @@ def test_no_profile_settings_file_should_log_warning(monkeypatch, caplog): settings_path = Path.cwd() / "configuration" / f"settings_{profile}.yaml" - with caplog.at_level(logging.WARNING, logger="selva"): - _get_settings_nocache() + _, paths = _get_settings_nocache() - assert ( - f"no settings file found for profile '{profile}' at {settings_path}" - in caplog.text - ) + assert (settings_path, False) in paths def test_override_settings_with_env_var(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "base") monkeypatch.setenv("SELVA__PROP", "override") - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings.prop == "override" @@ -261,7 +258,7 @@ def test_override_nested_settings_with_env_var(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "base") monkeypatch.setenv("SELVA__DICT__A", "override") - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings.dict.a == "override" @@ -374,7 +371,7 @@ def test_settings_with_env_var(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "env_var") monkeypatch.setenv("VAR_NAME", "test") - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings.name == "test" @@ -382,5 +379,5 @@ def test_settings_with_env_var_replaced_in_profile(monkeypatch): monkeypatch.chdir(Path(__file__).parent / "env_var") monkeypatch.setenv("SELVA_PROFILE", "profile") - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() assert settings.name == "profile" diff --git a/tests/conftest.py b/tests/conftest.py index 79eee1b..576e587 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,28 +1,14 @@ import pytest -from _pytest.logging import LogCaptureFixture -from loguru import logger +import structlog -logger.remove() - -@pytest.fixture -def caplog(caplog: LogCaptureFixture): - logger.enable("selva") - handler_id = logger.add( - caplog.handler, - format="{message}", - level=0, - filter=lambda record: record["level"].no >= caplog.handler.level, - enqueue=False, # Set to 'True' if your test is spawning child processes. - ) - yield caplog - logger.remove(handler_id) - logger.disable("selva") +@pytest.fixture(name="log_output") +def fixture_log_output(): + return structlog.testing.LogCapture() -@pytest.fixture -def reportlog(pytestconfig): - logging_plugin = pytestconfig.pluginmanager.getplugin("logging-plugin") - handler_id = logger.add(logging_plugin.report_handler, format="{message}") - yield - logger.remove(handler_id) +@pytest.fixture(autouse=True) +def fixture_configure_structlog(log_output): + structlog.configure( + processors=[log_output], + ) diff --git a/tests/di/test_service_function.py b/tests/di/test_service_function.py index 63cab60..fa44cda 100644 --- a/tests/di/test_service_function.py +++ b/tests/di/test_service_function.py @@ -1,4 +1,5 @@ import pytest +from structlog.testing import capture_logs from selva.di.container import Container from selva.di.decorator import service @@ -78,13 +79,17 @@ async def service_factory(): ioc.register(service_factory) -def test_provides_option_should_log_warning(ioc: Container, caplog): +def test_provides_option_should_log_warning(ioc: Container): @service(provides=Interface) async def factory() -> Interface: pass - ioc.register(factory) - assert "option 'provides' on a factory function has no effect" in caplog.text + with capture_logs() as cap_logs: + ioc.register(factory) + + assert ( + cap_logs[0]["event"] == "option 'provides' on a factory function has no effect" + ) async def test_sync_factory(ioc: Container): diff --git a/tests/di/test_service_generator.py b/tests/di/test_service_generator.py index 5e718bb..acf9efd 100644 --- a/tests/di/test_service_generator.py +++ b/tests/di/test_service_generator.py @@ -1,4 +1,5 @@ import pytest +from structlog.testing import capture_logs from selva.di.container import Container from selva.di.decorator import service @@ -91,6 +92,10 @@ async def service_factory(): ioc.register(service_factory) -def test_provides_option_should_log_warning(ioc: Container, caplog): - ioc.register(interface_factory) - assert "option 'provides' on a factory function has no effect" in caplog.text +def test_provides_option_should_log_warning(ioc: Container): + with capture_logs() as cap_logs: + ioc.register(interface_factory) + + assert ( + cap_logs[0]["event"] == "option 'provides' on a factory function has no effect" + ) diff --git a/tests/di/test_service_generator_async.py b/tests/di/test_service_generator_async.py index 5ac6828..b2d2451 100644 --- a/tests/di/test_service_generator_async.py +++ b/tests/di/test_service_generator_async.py @@ -1,4 +1,5 @@ import pytest +from structlog.testing import capture_logs from selva.di.container import Container from selva.di.decorator import service @@ -90,6 +91,10 @@ async def service_factory(): ioc.register(service_factory) -def test_provides_option_should_log_warning(ioc: Container, caplog): - ioc.register(interface_factory) - assert "option 'provides' on a factory function has no effect" in caplog.text +def test_provides_option_should_log_warning(ioc: Container): + with capture_logs() as cap_logs: + ioc.register(interface_factory) + + assert ( + cap_logs[0]["event"] == "option 'provides' on a factory function has no effect" + ) diff --git a/tests/ext/data/memcached/test_environment_variables.py b/tests/ext/data/memcached/test_environment_variables.py index b2589ab..fec7384 100644 --- a/tests/ext/data/memcached/test_environment_variables.py +++ b/tests/ext/data/memcached/test_environment_variables.py @@ -17,7 +17,7 @@ async def test_address_from_environment_variables(monkeypatch): monkeypatch.setenv("SELVA__DATA__MEMCACHED__DEFAULT__ADDRESS", MEMCACHED_ADDR) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() addr = MEMCACHED_ADDR.split(":") host = addr[0] diff --git a/tests/ext/data/memcached/test_service.py b/tests/ext/data/memcached/test_service.py index ac573ad..8528e8b 100644 --- a/tests/ext/data/memcached/test_service.py +++ b/tests/ext/data/memcached/test_service.py @@ -2,7 +2,6 @@ from importlib.util import find_spec import pytest - from emcache import ClusterEvents, ClusterManagment, MemcachedHostAddress from selva.configuration import Settings @@ -67,10 +66,14 @@ async def test_make_service_with_options(): class MyClusterEvents(ClusterEvents): - async def on_node_healthy(self, cluster_managment: ClusterManagment, host: MemcachedHostAddress): + async def on_node_healthy( + self, cluster_managment: ClusterManagment, host: MemcachedHostAddress + ): pass - async def on_node_unhealthy(self, cluster_managment: ClusterManagment, host: MemcachedHostAddress): + async def on_node_unhealthy( + self, cluster_managment: ClusterManagment, host: MemcachedHostAddress + ): pass diff --git a/tests/ext/data/redis/test_environment_variables.py b/tests/ext/data/redis/test_environment_variables.py index 3122fe4..b102005 100644 --- a/tests/ext/data/redis/test_environment_variables.py +++ b/tests/ext/data/redis/test_environment_variables.py @@ -19,7 +19,7 @@ async def test_url_from_environment_variables(monkeypatch): monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__URL", REDIS_URL) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() service = make_service("default")(settings) async for redis in service: @@ -39,7 +39,7 @@ async def test_url_username_password_from_environment_variables(monkeypatch): monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__URL", url) monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__USERNAME", username) monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__PASSWORD", password) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() service = make_service("default")(settings) async for redis in service: @@ -64,7 +64,7 @@ async def test_url_components_from_environment_variables(monkeypatch): monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__USERNAME", username) monkeypatch.setenv("SELVA__DATA__REDIS__DEFAULT__PASSWORD", password) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() service = make_service("default")(settings) async for redis in service: diff --git a/tests/ext/data/sqlalchemy/test_environment_variables.py b/tests/ext/data/sqlalchemy/test_environment_variables.py index 1864506..040b331 100644 --- a/tests/ext/data/sqlalchemy/test_environment_variables.py +++ b/tests/ext/data/sqlalchemy/test_environment_variables.py @@ -14,7 +14,7 @@ async def test_database_url_from_environment_variables(monkeypatch): url = "sqlite+aiosqlite:///:memory:" monkeypatch.setenv("SELVA__DATA__SQLALCHEMY__CONNECTIONS__DEFAULT__URL", url) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() engine_service = make_engine_service("default")(settings) async for engine in engine_service: @@ -35,7 +35,7 @@ async def test_database_url_username_password_from_environment_variables(monkeyp monkeypatch.setenv( "SELVA__DATA__SQLALCHEMY__CONNECTIONS__DEFAULT__PASSWORD", password ) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() engine_service = make_engine_service("default")(settings) async for engine in engine_service: @@ -66,7 +66,7 @@ async def test_database_url_components_from_environment_variables(monkeypatch): monkeypatch.setenv( "SELVA__DATA__SQLALCHEMY__CONNECTIONS__DEFAULT__PASSWORD", password ) - settings = _get_settings_nocache() + settings, _ = _get_settings_nocache() engine_service = make_engine_service("default")(settings) async for engine in engine_service: