diff --git a/.all-contributorsrc b/.all-contributorsrc index dfc3870821..57aba010fc 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1755,6 +1755,15 @@ "contributions": [ "doc" ] + }, + { + "login": "jderrien", + "name": "jderrien", + "avatar_url": "https://avatars.githubusercontent.com/u/145396?v=4", + "profile": "https://github.com/jderrien", + "contributions": [ + "doc" + ] } ], "contributorsPerLine": 7, diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index c8be7035f9..067acffe57 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4 - name: Download artifact - uses: dawidd6/action-download-artifact@v3 + uses: dawidd6/action-download-artifact@v5 with: workflow_conclusion: success run_id: ${{ github.event.workflow_run.id }} diff --git a/README.md b/README.md index 4848d7571a..77f36e736e 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,7 @@ see [the contribution guide](CONTRIBUTING.rst). Joren Six
Joren Six

📖 + jderrien
jderrien

📖 diff --git a/docs/examples/caching/__init__.py b/docs/examples/caching/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/examples/caching/cache.py b/docs/examples/caching/cache.py new file mode 100644 index 0000000000..deec9eaea1 --- /dev/null +++ b/docs/examples/caching/cache.py @@ -0,0 +1,22 @@ +from litestar import Litestar, get +from litestar.config.response_cache import CACHE_FOREVER + + +@get("/cached", cache=True) +async def my_cached_handler() -> str: + return "cached" + + +@get("/cached-seconds", cache=120) # seconds +async def my_cached_handler_seconds() -> str: + return "cached for 120 seconds" + + +@get("/cached-forever", cache=CACHE_FOREVER) +async def my_cached_handler_forever() -> str: + return "cached forever" + + +app = Litestar( + [my_cached_handler, my_cached_handler_seconds, my_cached_handler_forever], +) diff --git a/docs/examples/caching/key_builder.py b/docs/examples/caching/key_builder.py new file mode 100644 index 0000000000..762fb27476 --- /dev/null +++ b/docs/examples/caching/key_builder.py @@ -0,0 +1,9 @@ +from litestar import Litestar, Request +from litestar.config.response_cache import ResponseCacheConfig + + +def key_builder(request: Request) -> str: + return request.url.path + request.headers.get("my-header", "") + + +app = Litestar([], response_cache_config=ResponseCacheConfig(key_builder=key_builder)) diff --git a/docs/examples/caching/key_builder_for_route_handler.py b/docs/examples/caching/key_builder_for_route_handler.py new file mode 100644 index 0000000000..5b4c4d1cb2 --- /dev/null +++ b/docs/examples/caching/key_builder_for_route_handler.py @@ -0,0 +1,13 @@ +from litestar import Litestar, Request, get + + +def key_builder(request: Request) -> str: + return request.url.path + request.headers.get("my-header", "") + + +@get("/cached-path", cache=True, cache_key_builder=key_builder) +async def cached_handler() -> str: + return "cached" + + +app = Litestar([cached_handler]) diff --git a/docs/examples/caching/redis_store.py b/docs/examples/caching/redis_store.py new file mode 100644 index 0000000000..8832aab553 --- /dev/null +++ b/docs/examples/caching/redis_store.py @@ -0,0 +1,20 @@ +import asyncio + +from litestar import Litestar, get +from litestar.config.response_cache import ResponseCacheConfig +from litestar.stores.redis import RedisStore + + +@get(cache=10) +async def something() -> str: + await asyncio.sleep(1) + return "something" + + +redis_store = RedisStore.with_client(url="redis://localhost/", port=6379, db=0) +cache_config = ResponseCacheConfig(store="redis_backed_store") +app = Litestar( + [something], + stores={"redis_backed_store": redis_store}, + response_cache_config=cache_config, +) diff --git a/docs/examples/plugins/flash_messages/jinja.py b/docs/examples/plugins/flash_messages/jinja.py index 6772697642..27721ddfa5 100644 --- a/docs/examples/plugins/flash_messages/jinja.py +++ b/docs/examples/plugins/flash_messages/jinja.py @@ -1,9 +1,13 @@ from litestar import Litestar from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.middleware.session.server_side import ServerSideSessionConfig from litestar.plugins.flash import FlashConfig, FlashPlugin from litestar.template.config import TemplateConfig template_config = TemplateConfig(engine=JinjaTemplateEngine, directory="templates") flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) -app = Litestar(plugins=[flash_plugin]) +app = Litestar( + plugins=[flash_plugin], + middleware=[ServerSideSessionConfig().middleware], +) diff --git a/docs/examples/plugins/flash_messages/mako.py b/docs/examples/plugins/flash_messages/mako.py index a5ce038eab..7bc9c637ec 100644 --- a/docs/examples/plugins/flash_messages/mako.py +++ b/docs/examples/plugins/flash_messages/mako.py @@ -1,9 +1,13 @@ from litestar import Litestar from litestar.contrib.mako import MakoTemplateEngine +from litestar.middleware.session.server_side import ServerSideSessionConfig from litestar.plugins.flash import FlashConfig, FlashPlugin from litestar.template.config import TemplateConfig template_config = TemplateConfig(engine=MakoTemplateEngine, directory="templates") flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) -app = Litestar(plugins=[flash_plugin]) +app = Litestar( + plugins=[flash_plugin], + middleware=[ServerSideSessionConfig().middleware], +) diff --git a/docs/examples/plugins/flash_messages/minijinja.py b/docs/examples/plugins/flash_messages/minijinja.py index 0ea2ce0f8e..26ae24be13 100644 --- a/docs/examples/plugins/flash_messages/minijinja.py +++ b/docs/examples/plugins/flash_messages/minijinja.py @@ -1,9 +1,13 @@ from litestar import Litestar from litestar.contrib.minijinja import MiniJinjaTemplateEngine +from litestar.middleware.session.server_side import ServerSideSessionConfig from litestar.plugins.flash import FlashConfig, FlashPlugin from litestar.template.config import TemplateConfig template_config = TemplateConfig(engine=MiniJinjaTemplateEngine, directory="templates") flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) -app = Litestar(plugins=[flash_plugin]) +app = Litestar( + plugins=[flash_plugin], + middleware=[ServerSideSessionConfig().middleware], +) diff --git a/docs/examples/plugins/flash_messages/usage.py b/docs/examples/plugins/flash_messages/usage.py index 914919ea0f..b9cfd71ab9 100644 --- a/docs/examples/plugins/flash_messages/usage.py +++ b/docs/examples/plugins/flash_messages/usage.py @@ -1,5 +1,6 @@ from litestar import Litestar, Request, get from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.middleware.session.server_side import ServerSideSessionConfig from litestar.plugins.flash import FlashConfig, FlashPlugin, flash from litestar.response import Template from litestar.template.config import TemplateConfig @@ -23,4 +24,9 @@ async def index(request: Request) -> Template: ) -app = Litestar(plugins=[flash_plugin], route_handlers=[index], template_config=template_config) +app = Litestar( + plugins=[flash_plugin], + route_handlers=[index], + template_config=template_config, + middleware=[ServerSideSessionConfig().middleware], +) diff --git a/docs/release-notes/changelog.rst b/docs/release-notes/changelog.rst index d950d092a9..211cc0b733 100644 --- a/docs/release-notes/changelog.rst +++ b/docs/release-notes/changelog.rst @@ -3,6 +3,318 @@ 2.x Changelog ============= +.. changelog:: 2.9.0 + :date: 2024-06-02 + + .. change:: asgi lifespan msg after lifespan context exception + :type: bugfix + :pr: 3315 + + An exception raised within an asgi lifespan context manager would result in a "lifespan.startup.failed" message + being sent after we've already sent a "lifespan.startup.complete" message. This would cause uvicorn to raise a + ``STATE_TRANSITION_ERROR`` assertion error due to their check for that condition , if asgi lifespan is + forced (i.e., with ``$ uvicorn test_apps.test_app:app --lifespan on``). + + E.g., + + .. code-block:: + + During handling of the above exception, another exception occurred: + + Traceback (most recent call last): + File "/home/peter/.local/share/pdm/venvs/litestar-dj-FOhMr-3.8/lib/python3.8/site-packages/uvicorn/lifespan/on.py", line 86, in main + await app(scope, self.receive, self.send) + File "/home/peter/.local/share/pdm/venvs/litestar-dj-FOhMr-3.8/lib/python3.8/site-packages/uvicorn/middleware/proxy_headers.py", line 69, in __call__ + return await self.app(scope, receive, send) + File "/home/peter/PycharmProjects/litestar/litestar/app.py", line 568, in __call__ + await self.asgi_router.lifespan(receive=receive, send=send) # type: ignore[arg-type] + File "/home/peter/PycharmProjects/litestar/litestar/_asgi/asgi_router.py", line 180, in lifespan + await send(failure_message) + File "/home/peter/.local/share/pdm/venvs/litestar-dj-FOhMr-3.8/lib/python3.8/site-packages/uvicorn/lifespan/on.py", line 116, in send + assert not self.startup_event.is_set(), STATE_TRANSITION_ERROR + AssertionError: Got invalid state transition on lifespan protocol. + + This PR modifies ``ASGIRouter.lifespan()`` so that it sends a shutdown failure message if we've already confirmed startup. + + .. change:: bug when pydantic==1.10 is installed + :type: bugfix + :pr: 3335 + :issue: 3334 + + Fix a bug introduced in #3296 where it failed to take into account that the ``pydantic_v2`` variable could be + ``Empty``. + + + .. change:: OpenAPI router and controller on same app. + :type: bugfix + :pr: 3338 + :issue: 3337 + + Fixes an :exc`ImproperlyConfiguredException` where an app that explicitly registers an ``OpenAPIController`` on + the application, and implicitly uses the OpenAPI router via the `OpenAPIConfig` object. This was caused by the + two different handlers being given the same name as defined in ``litestar.constants``. + + PR adds a distinct name for use by the handler that serves ``openapi.json`` on the controller. + + + .. change:: pydantic v2 import tests for pydantic v1.10.15 + :type: bugfix + :pr: 3347 + :issue: 3348 + + Fixes bug with Pydantic V1 environment test where the test was run against v2. Adds assertion for version to the test. + + Fixes a bug exposed by above that relied on pydantic not having ``v1`` in the package namespace if ``v1`` is + installed. This doesn't hold true after pydantic's ``1.10.15`` release. + + + .. change:: schema for generic wrapped return types with DTO + :type: bugfix + :pr: 3371 + :issue: 2929 + + Fix schema generated for DTOs where the supported type is wrapped in a generic outer type. + + + Prior behavior of using the ``backend.annotation`` as the basis for generating the openapi schema for the + represented type is not applicable for the case where the DTO supported type is wrapped in a generic outer + object. In that case ``backend.annotation`` only represents the type of the attribute on the generic type that + holds the DTO supported type annotation. + + This change detects the case where we unwrap an outer generic type, and rebuilds the generic annotation in a + manner appropriate for schema generation, before generating the schema for the annotation. It does this by + substituting the DTOs transfer model for the original model in the original annotations type arguments. + + .. change:: Ambiguous default warning for no signature default + :type: bugfix + :pr: 3378 + :issue: 3372 + + We now only issue a single warning for the case where a default value is supplied via ``Parameter()`` and not + via a regular signature default. + + + .. change:: Path param consumed by dependency treated as unconsumed + :type: bugfix + :pr: 3380 + :issue: 3369 + + Consider parameters defined in handler dependencies in order to determine if a path parameter has been consumed + for openapi generation purposes. + + Fixes an issue where path parameters not consumed by the handler, but consumed by dependencies would cause an + :exc`ImproperlyConfiguredException`. + + .. change:: "name" and "in" should not be included in openapi headers + :type: bugfix + :pr: 3417 + :issue: 3416 + + Exclude the "name" and "in" fields from openapi schema generated for headers. + + Add ``BaseSchemaObject._iter_fields()`` method that allows schema types to + define the fields that should be included in their openapi schema representation + and override that method for ``OpenAPIHeader``. + + .. change:: top-level import of optional package + :type: bugfix + :pr: 3418 + :issue: 3415 + + Fix import from ``contrib.minijinja`` without handling for case where dependency is not installed. + + + .. change:: regular handler under mounted app + :type: bugfix + :pr: 3430 + :issue: 3429 + + Fix an issue where a regular handler under a mounted asgi app would prevent a + request from routing through the mounted application if the request path + contained the path of the regular handler as a substring. + + .. change:: logging to file with structlog + :type: bugfix + :pr: 3425 + + Fix and issue with converting ``StructLoggingConfig`` to dict during call to + ``configure()`` when the config object has a custom logger factory that + references a ``TextIO`` object, which cannot be pickled. + + .. change:: clear session cookie if new session exceeds ``CHUNK_SIZE`` + :type: bugfix + :pr: 3446 + :issue: 3441 + + Fix an issue where the connection session cookie is not cleared if the response + session is stored across multiple cookies. + + .. change:: flash messages were not displayed on Redirect + :type: bugfix + :pr: 3420 + :issue: 3325 + + Fix an issue where flashed messages were not shown after a redirect + + .. change:: Validation of optional sequence in multipart data with one value + :type: bugfix + :pr: 3408 + :issue: 3407 + + A ``Sequence[UploadFile] | None`` would not pass validation when a single value + was provided for a structured type, e.g. dataclass. + + .. change:: field not optional if default value + :type: bugfix + :pr: 3476 + :issue: 3471 + + Fix issue where a pydantic v1 field annotation is wrapped with ``Optional`` if + it is marked not required, but has a default value. + + .. change:: prevent starting multiple responses + :type: bugfix + :pr: 3479 + + Prevent the app's exception handler middleware from starting a response after + one has already started. + + When something in the middleware stack raises an exception after a + "http.response.start" message has already been sent, we end up with long + exception chains that obfuscate the original exception. + + This change implements tracking of when a response has started, and if so, we + immediately raise the exception instead of sending it through the usual exception + handling code path. + + .. change:: logging middleware with multi-body response + :type: bugfix + :pr: 3478 + :issue: 3477 + + Prevent logging middleware from failing with a :exc:`KeyError` when a response + sends multiple "http.response.body" messages. + + .. change:: handle dto type nested in mapping + :type: bugfix + :pr: 3486 + :issue: 3463 + + Added handling for transferring data from a transfer model, to a DTO supported + instance when the DTO supported type is nested in a mapping. + + I.e, handles this case: + + .. code-block:: python + + @dataclass + class NestedDC: + a: int + b: str + + @dataclass + class DC: + nested_mapping: Dict[str, NestedDC] + + .. change:: examples omitted in schema produced by dto + :type: bugfix + :pr: 3510 + :issue: 3505 + + Fixes issue where a ``BodyKwarg`` instance provided as metadata to a data type + annotation was ignored for OpenAPI schema generation when the data type is + managed by a DTO. + + .. change:: fix handling validation of subscribed generics + :type: bugfix + :pr: 3519 + + Fix a bug that would lead to a :exc:`TypeError` when subscribed generics were + used in a route handler signature and subject to validation. + + .. code-block:: python + + from typing import Generic, TypeVar + from litestar import get + from litestar.testing import create_test_client + + T = TypeVar("T") + + class Foo(Generic[T]): + pass + + async def provide_foo() -> Foo[str]: + return Foo() + + @get("/", dependencies={"foo": provide_foo}) + async def something(foo: Foo[str]) -> None: + return None + + with create_test_client([something]) as client: + client.get("/") + + + .. change:: exclude static file from schema + :type: bugfix + :pr: 3509 + :issue: 3374 + + Exclude static file routes created with ``create_static_files_router`` from the OpenAPI schema by default + + .. change:: use re.match instead of re.search for mounted app path (#3501) + :type: bugfix + :pr: 3511 + :issue: 3501 + + When mounting an app, path resolution uses ``re.search`` instead or ``re.match``, + thus mounted app matches any path which contains mount path. + + .. change:: do not log exceptions twice, deprecate ``traceback_line_limit`` and fix ``pretty_print_tty`` + :type: bugfix + :pr: 3507 + :issue: 3228 + + * The wording of the log message, when logging an exception, has been updated. + * For structlog, the ``traceback`` field in the log message (which contained a + truncated stacktrace) has been removed. The ``exception`` field is still around and contains the full stacktrace. + * The option ``traceback_line_limit`` has been deprecated. The value is now ignored, the full stacktrace will be logged. + + + .. change:: YAML schema dump + :type: bugfix + :pr: 3537 + + Fix an issue in the OpenAPI YAML schema dump logic of ``OpenAPIController`` + where the endpoint for the OpenAPI YAML schema file returns an empty response + if a request has been made to the OpenAPI JSON schema previously due to an + incorrect variable check. + + + .. change:: Add async ``websocket_connect`` to ``AsyncTestClient`` + :type: feature + :pr: 3328 + :issue: 3133 + + Add async ``websocket_connect`` to ``AsyncTestClient`` + + + .. change:: add ``SecretString`` and ``SecretBytes`` datastructures + :type: feature + :pr: 3322 + :issue: 1312, 3248 + + + Implement ``SecretString`` and ``SecretBytes`` data structures to hide sensitive + data in tracebacks, etc. + + .. change:: Deprecate subclassing route handler decorators + :type: feature + :pr: 3439 + + Deprecation for the 2.x release line of the semantic route handler classes + removed in #3436. + + .. changelog:: 2.8.3 :date: 2024-05-06 diff --git a/docs/usage/caching.rst b/docs/usage/caching.rst index d3c26bdc12..3eaf68cb8e 100644 --- a/docs/usage/caching.rst +++ b/docs/usage/caching.rst @@ -7,13 +7,9 @@ Caching responses Sometimes it's desirable to cache some responses, especially if these involve expensive calculations, or when polling is expected. Litestar comes with a simple mechanism for caching: -.. code-block:: python - - from litestar import get - - - @get("/cached-path", cache=True) - def my_cached_handler() -> str: ... +.. literalinclude:: /examples/caching/cache.py + :language: python + :lines: 1, 4-8 By setting :paramref:`~litestar.handlers.HTTPRouteHandler.cache` to ``True``, the response from the handler will be cached. If no ``cache_key_builder`` is set in the route handler, caching for the route handler will be @@ -25,31 +21,22 @@ enabled for the :attr:`~.config.response_cache.ResponseCacheConfig.default_expir Alternatively you can specify the number of seconds to cache the responses from the given handler like so: -.. code-block:: python +.. literalinclude:: /examples/caching/cache.py + :language: python :caption: Caching the response for 120 seconds by setting the :paramref:`~litestar.handlers.HTTPRouteHandler.cache` parameter to the number of seconds to cache the response. + :lines: 1, 9-13 :emphasize-lines: 4 - from litestar import get - - - @get("/cached-path", cache=120) # seconds - def my_cached_handler() -> str: ... - - If you want the response to be cached indefinitely, you can pass the :class:`~.config.response_cache.CACHE_FOREVER` sentinel instead: -.. code-block:: python +.. literalinclude:: /examples/caching/cache.py + :language: python :caption: Caching the response indefinitely by setting the :paramref:`~litestar.handlers.HTTPRouteHandler.cache` parameter to :class:`~litestar.config.response_cache.CACHE_FOREVER`. - - from litestar import get - from litestar.config.response_cache import CACHE_FOREVER - - - @get("/cached-path", cache=CACHE_FOREVER) - def my_cached_handler() -> str: ... + :lines: 1, 3, 14-18 + :emphasize-lines: 5 Configuration ------------- @@ -63,45 +50,20 @@ Changing where data is stored By default, caching will use the :class:`~.stores.memory.MemoryStore`, but it can be configured with any :class:`~.stores.base.Store`, for example :class:`~.stores.redis.RedisStore`: -.. code-block:: python +.. literalinclude:: /examples/caching/redis_store.py + :language: python :caption: Using Redis as the cache store. - from litestar.config.cache import ResponseCacheConfig - from litestar.stores.redis import RedisStore - - redis_store = RedisStore(url="redis://localhost/", port=6379, db=0) - - cache_config = ResponseCacheConfig(store=redis_store) - - Specifying a cache key builder ++++++++++++++++++++++++++++++ Litestar uses the request's path + sorted query parameters as the cache key. This can be adjusted by providing a "key builder" function, either at application or route handler level. -.. code-block:: python +.. literalinclude:: /examples/caching/key_builder.py + :language: python :caption: Using a custom cache key builder. - from litestar import Litestar, Request - from litestar.config.cache import ResponseCacheConfig - - - def key_builder(request: Request) -> str: - return request.url.path + request.headers.get("my-header", "") - - - app = Litestar([], cache_config=ResponseCacheConfig(key_builder=key_builder)) - -.. code-block:: python +.. literalinclude:: /examples/caching/key_builder_for_route_handler.py + :language: python :caption: Using a custom cache key builder for a specific route handler. - - from litestar import Litestar, Request, get - - - def key_builder(request: Request) -> str: - return request.url.path + request.headers.get("my-header", "") - - - @get("/cached-path", cache=True, cache_key_builder=key_builder) - def cached_handler() -> str: ... diff --git a/docs/usage/logging.rst b/docs/usage/logging.rst index c39861aea4..85269282c6 100644 --- a/docs/usage/logging.rst +++ b/docs/usage/logging.rst @@ -18,7 +18,7 @@ Application and request level loggers can be configured using the :class:`~lites logging_config = LoggingConfig( - root={"level": logging.getLevelName(logging.INFO), "handlers": ["console"]}, + root={"level": "INFO", "handlers": ["queue_listener"]}, formatters={ "standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"} }, diff --git a/docs/usage/plugins/flash_messages.rst b/docs/usage/plugins/flash_messages.rst index 8ff46b8db6..40e61554bc 100644 --- a/docs/usage/plugins/flash_messages.rst +++ b/docs/usage/plugins/flash_messages.rst @@ -57,6 +57,7 @@ Breakdown +++++++++ #. Here we import the requires classes and functions from the Litestar package and related plugins. +#. Flash messages requires a valid session configuration, so we create and enable the ``ServerSideSession`` middleware. #. We then create our ``TemplateConfig`` and ``FlashConfig`` instances, each setting up the configuration for the template engine and flash messages, respectively. #. A single route handler named ``index`` is defined using the ``@get()`` decorator. diff --git a/litestar/config/csrf.py b/litestar/config/csrf.py index 5094a5b7c8..225dc23daa 100644 --- a/litestar/config/csrf.py +++ b/litestar/config/csrf.py @@ -34,7 +34,7 @@ class CSRFConfig: """The value to set in the ``SameSite`` attribute of the cookie.""" cookie_domain: str | None = field(default=None) """Specifies which hosts can receive the cookie.""" - safe_methods: set[Method] = field(default_factory=lambda: {"GET", "HEAD"}) + safe_methods: set[Method] = field(default_factory=lambda: {"GET", "HEAD", "OPTIONS"}) """A set of "safe methods" that can set the cookie.""" exclude: str | list[str] | None = field(default=None) """A pattern or list of patterns to skip in the CSRF middleware.""" diff --git a/litestar/exceptions/responses/__init__.py b/litestar/exceptions/responses/__init__.py index e999e13a4f..9ed1df1c52 100644 --- a/litestar/exceptions/responses/__init__.py +++ b/litestar/exceptions/responses/__init__.py @@ -37,7 +37,6 @@ def to_response(self, request: Request | None = None) -> Response: Returns: A response instance. """ - from litestar.response import Response content: Any = {k: v for k, v in asdict(self).items() if k not in ("headers", "media_type") and v is not None} type_encoders = _debug_response._get_type_encoders_for_request(request) if request is not None else None diff --git a/litestar/logging/config.py b/litestar/logging/config.py index c084c5d034..2b1587c89f 100644 --- a/litestar/logging/config.py +++ b/litestar/logging/config.py @@ -10,7 +10,7 @@ from litestar.exceptions import ImproperlyConfiguredException, MissingDependencyException from litestar.serialization.msgspec_hooks import _msgspec_json_encoder from litestar.utils.dataclass import simple_asdict -from litestar.utils.deprecation import deprecated +from litestar.utils.deprecation import deprecated, warn_deprecation __all__ = ("BaseLoggingConfig", "LoggingConfig", "StructLoggingConfig") @@ -90,34 +90,45 @@ def _get_default_handlers() -> dict[str, dict[str, Any]]: def _default_exception_logging_handler_factory( - is_struct_logger: bool, traceback_line_limit: int + is_struct_logger: bool, + traceback_line_limit: int, ) -> ExceptionLoggingHandler: """Create an exception logging handler function. Args: is_struct_logger: Whether the logger is a structlog instance. traceback_line_limit: Maximal number of lines to log from the - traceback. + traceback. This parameter is deprecated and ignored. Returns: An exception logging handler. """ - def _default_exception_logging_handler(logger: Logger, scope: Scope, tb: list[str]) -> None: - # we limit the length of the stack trace to 20 lines. - first_line = tb.pop(0) + if traceback_line_limit != -1: + warn_deprecation( + version="2.9.0", + deprecated_name="traceback_line_limit", + kind="parameter", + info="The value is ignored. Use a custom 'exception_logging_handler' instead.", + removal_in="3.0", + ) + + if is_struct_logger: - if is_struct_logger: + def _default_exception_logging_handler(logger: Logger, scope: Scope, tb: list[str]) -> None: logger.exception( - "Uncaught Exception", + "Uncaught exception", connection_type=scope["type"], path=scope["path"], - traceback="".join(tb[-traceback_line_limit:]), ) - else: - stack_trace = first_line + "".join(tb[-traceback_line_limit:]) + + else: + + def _default_exception_logging_handler(logger: Logger, scope: Scope, tb: list[str]) -> None: logger.exception( - "exception raised on %s connection to route %s\n\n%s", scope["type"], scope["path"], stack_trace + "Uncaught exception (connection_type=%s, path=%s):", + scope["type"], + scope["path"], ) return _default_exception_logging_handler @@ -131,7 +142,11 @@ class BaseLoggingConfig(ABC): log_exceptions: Literal["always", "debug", "never"] """Should exceptions be logged, defaults to log exceptions when ``app.debug == True``'""" traceback_line_limit: int - """Max number of lines to print for exception traceback""" + """Max number of lines to print for exception traceback. + + .. deprecated:: 2.9.0 + This parameter is deprecated and ignored. It will be removed in a future release. + """ exception_logging_handler: ExceptionLoggingHandler | None """Handler function for logging exceptions.""" @@ -205,8 +220,12 @@ class LoggingConfig(BaseLoggingConfig): """Should the root logger be configured, defaults to True for ease of configuration.""" log_exceptions: Literal["always", "debug", "never"] = field(default="debug") """Should exceptions be logged, defaults to log exceptions when 'app.debug == True'""" - traceback_line_limit: int = field(default=20) - """Max number of lines to print for exception traceback""" + traceback_line_limit: int = field(default=-1) + """Max number of lines to print for exception traceback. + + .. deprecated:: 2.9.0 + This parameter is deprecated and ignored. It will be removed in a future release. + """ exception_logging_handler: ExceptionLoggingHandler | None = field(default=None) """Handler function for logging exceptions.""" @@ -421,8 +440,12 @@ class StructLoggingConfig(BaseLoggingConfig): """Whether to cache the logger configuration and reuse.""" log_exceptions: Literal["always", "debug", "never"] = field(default="debug") """Should exceptions be logged, defaults to log exceptions when 'app.debug == True'""" - traceback_line_limit: int = field(default=20) - """Max number of lines to print for exception traceback""" + traceback_line_limit: int = field(default=-1) + """Max number of lines to print for exception traceback. + + .. deprecated:: 2.9.0 + This parameter is deprecated and ignored. It will be removed in a future release. + """ exception_logging_handler: ExceptionLoggingHandler | None = field(default=None) """Handler function for logging exceptions.""" pretty_print_tty: bool = field(default=True) @@ -430,9 +453,9 @@ class StructLoggingConfig(BaseLoggingConfig): def __post_init__(self) -> None: if self.processors is None: - self.processors = default_structlog_processors(not sys.stderr.isatty() and self.pretty_print_tty) + self.processors = default_structlog_processors(as_json=self.as_json()) if self.logger_factory is None: - self.logger_factory = default_logger_factory(not sys.stderr.isatty() and self.pretty_print_tty) + self.logger_factory = default_logger_factory(as_json=self.as_json()) if self.log_exceptions != "never" and self.exception_logging_handler is None: self.exception_logging_handler = _default_exception_logging_handler_factory( is_struct_logger=True, traceback_line_limit=self.traceback_line_limit @@ -445,15 +468,16 @@ def __post_init__(self) -> None: formatters={ "standard": { "()": structlog.stdlib.ProcessorFormatter, - "processors": default_structlog_standard_lib_processors( - as_json=not sys.stderr.isatty() and self.pretty_print_tty - ), + "processors": default_structlog_standard_lib_processors(as_json=self.as_json()), } } ) except ImportError: self.standard_lib_logging_config = LoggingConfig() + def as_json(self) -> bool: + return not (sys.stderr.isatty() and self.pretty_print_tty) + def configure(self) -> GetLogger: """Return logger with the given configuration. diff --git a/litestar/openapi/controller.py b/litestar/openapi/controller.py index effc2ea3d3..61f1148b1d 100644 --- a/litestar/openapi/controller.py +++ b/litestar/openapi/controller.py @@ -172,7 +172,7 @@ def retrieve_schema_yaml(self, request: Request[Any, Any, Any]) -> ASGIResponse: from yaml import dump as dump_yaml if self.should_serve_endpoint(request): - if not self._dumped_json_schema: + if not self._dumped_yaml_schema: schema_json = decode_json(self._get_schema_as_json(request)) schema_yaml = dump_yaml(schema_json, default_flow_style=False) self._dumped_yaml_schema = schema_yaml.encode("utf-8") diff --git a/litestar/plugins/flash.py b/litestar/plugins/flash.py index ebede72ed8..b7d67f7eb1 100644 --- a/litestar/plugins/flash.py +++ b/litestar/plugins/flash.py @@ -20,6 +20,7 @@ from collections.abc import Callable from litestar.config.app import AppConfig + from litestar.connection.base import AuthT, StateT, UserT from litestar.template import TemplateConfig @@ -69,7 +70,7 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig: def flash( - request: Request, + request: Request[UserT, AuthT, StateT], message: Any, category: str, ) -> None: diff --git a/pyproject.toml b/pyproject.toml index b811d0054c..a083d64bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ maintainers = [ name = "litestar" readme = "README.md" requires-python = ">=3.8,<4.0" -version = "2.8.3" +version = "2.9.0" [project.urls] Blog = "https://blog.litestar.dev" diff --git a/tests/unit/test_logging/test_logging_config.py b/tests/unit/test_logging/test_logging_config.py index e318ed730f..80731ffe38 100644 --- a/tests/unit/test_logging/test_logging_config.py +++ b/tests/unit/test_logging/test_logging_config.py @@ -219,3 +219,16 @@ def test_customizing_handler(handlers: Any, expected_handler_class: Any, monkeyp else: formatter = root_logger_handler.formatter assert formatter._fmt == log_format + + +@pytest.mark.parametrize( + "traceback_line_limit, expected_warning_deprecation_called", + [ + [-1, False], + [20, True], + ], +) +def test_traceback_line_limit_deprecation(traceback_line_limit: int, expected_warning_deprecation_called: bool) -> None: + with patch("litestar.logging.config.warn_deprecation") as mock_warning_deprecation: + LoggingConfig(traceback_line_limit=traceback_line_limit) + assert mock_warning_deprecation.called is expected_warning_deprecation_called diff --git a/tests/unit/test_logging/test_structlog_config.py b/tests/unit/test_logging/test_structlog_config.py index 4f8e695496..30e80b7e5c 100644 --- a/tests/unit/test_logging/test_structlog_config.py +++ b/tests/unit/test_logging/test_structlog_config.py @@ -1,6 +1,7 @@ import datetime import sys from typing import Callable +from unittest.mock import patch import pytest import structlog @@ -155,3 +156,19 @@ def test_structlog_config_specify_processors(capsys: CaptureFixture) -> None: {"key": "value1", "event": "message1"}, {"key": "value2", "event": "message2"}, ] + + +@pytest.mark.parametrize( + "isatty, pretty_print_tty, expected_as_json", + [ + (True, True, False), + (True, False, True), + (False, True, True), + (False, False, True), + ], +) +def test_structlog_config_as_json(isatty: bool, pretty_print_tty: bool, expected_as_json: bool) -> None: + with patch("litestar.logging.config.sys.stderr.isatty") as isatty_mock: + isatty_mock.return_value = isatty + logging_config = StructLoggingConfig(pretty_print_tty=pretty_print_tty) + assert logging_config.as_json() is expected_as_json diff --git a/tests/unit/test_middleware/test_exception_handler_middleware.py b/tests/unit/test_middleware/test_exception_handler_middleware.py index cfa10fd7e8..a1540d353c 100644 --- a/tests/unit/test_middleware/test_exception_handler_middleware.py +++ b/tests/unit/test_middleware/test_exception_handler_middleware.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock import pytest -from _pytest.capture import CaptureFixture from pytest_mock import MockerFixture from starlette.exceptions import HTTPException as StarletteHTTPException from structlog.testing import capture_logs @@ -205,12 +204,10 @@ def handler() -> None: if should_log: assert len(caplog.records) == 1 assert caplog.records[0].levelname == "ERROR" - assert caplog.records[0].message.startswith( - "exception raised on http connection to route /test\n\nTraceback (most recent call last):\n" - ) + assert caplog.records[0].message.startswith("Uncaught exception (connection_type=http, path=/test):") else: assert not caplog.records - assert "exception raised on http connection request to route /test" not in response.text + assert "Uncaught exception" not in response.text @pytest.mark.parametrize( @@ -228,7 +225,6 @@ def handler() -> None: ) def test_exception_handler_struct_logging( get_logger: "GetLogger", - capsys: CaptureFixture, is_debug: bool, logging_config: Optional[LoggingConfig], should_log: bool, @@ -251,50 +247,12 @@ def handler() -> None: assert len(cap_logs) == 1 assert cap_logs[0].get("connection_type") == "http" assert cap_logs[0].get("path") == "/test" - assert cap_logs[0].get("traceback") - assert cap_logs[0].get("event") == "Uncaught Exception" + assert cap_logs[0].get("event") == "Uncaught exception" assert cap_logs[0].get("log_level") == "error" else: assert not cap_logs -def test_traceback_truncate_default_logging( - get_logger: "GetLogger", - caplog: "LogCaptureFixture", -) -> None: - @get("/test") - def handler() -> None: - raise ValueError("Test debug exception") - - app = Litestar([handler], logging_config=LoggingConfig(log_exceptions="always", traceback_line_limit=1)) - - with caplog.at_level("ERROR", "litestar"), TestClient(app=app) as client: - client.app.logger = get_logger("litestar") - response = client.get("/test") - assert response.status_code == HTTP_500_INTERNAL_SERVER_ERROR - assert "Internal Server Error" in response.text - - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "ERROR" - assert caplog.records[0].message == ( - "exception raised on http connection to route /test\n\nTraceback (most recent call last):\nValueError: Test debug exception\n" - ) - - -def test_traceback_truncate_struct_logging() -> None: - @get("/test") - def handler() -> None: - raise ValueError("Test debug exception") - - app = Litestar([handler], logging_config=StructLoggingConfig(log_exceptions="always", traceback_line_limit=1)) - - with TestClient(app=app) as client, capture_logs() as cap_logs: - response = client.get("/test") - assert response.status_code == HTTP_500_INTERNAL_SERVER_ERROR - assert len(cap_logs) == 1 - assert cap_logs[0].get("traceback") == "ValueError: Test debug exception\n" - - def handler(_: Any, __: Any) -> Any: return None @@ -358,9 +316,7 @@ def handler() -> None: assert "Test debug exception" in response.text assert len(caplog.records) == 1 assert caplog.records[0].levelname == "ERROR" - assert caplog.records[0].message.startswith( - "exception raised on http connection to route /test\n\nTraceback (most recent call last):\n" - ) + assert caplog.records[0].message.startswith("Uncaught exception (connection_type=http, path=/test):") def test_get_symbol_name_where_type_doesnt_support_bool() -> None: diff --git a/tests/unit/test_openapi/test_integration.py b/tests/unit/test_openapi/test_integration.py index 2a84b32fdf..31af60e5fa 100644 --- a/tests/unit/test_openapi/test_integration.py +++ b/tests/unit/test_openapi/test_integration.py @@ -112,6 +112,23 @@ def test_openapi_json_not_allowed(person_controller: type[Controller], pet_contr assert response.status_code == HTTP_404_NOT_FOUND +@pytest.mark.parametrize( + "schema_paths", + [ + ("/schema/openapi.json", "/schema/openapi.yaml"), + ("/schema/openapi.yaml", "/schema/openapi.json"), + ], +) +def test_openapi_controller_internal_schema_conversion(schema_paths: list[str]) -> None: + openapi_config = OpenAPIConfig("Example API", "1.0.0", openapi_controller=OpenAPIController) + + with create_test_client([], openapi_config=openapi_config) as client: + for schema_path in schema_paths: + response = client.get(schema_path) + assert response.status_code == HTTP_200_OK + assert "Example API" in response.text + + def test_openapi_custom_path(openapi_controller: type[OpenAPIController] | None) -> None: openapi_config = OpenAPIConfig( title="my title", version="1.0.0", path="/custom_schema_path", openapi_controller=openapi_controller