From c8fa78439a99d80519c1d5df196c8c8f120b2039 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Sun, 12 Jul 2026 14:29:25 +0200 Subject: [PATCH] feat(lilya): Add Lilya integration Add first-class Lilya framework instrumentation for route transaction names, request data, handled HTTP exceptions, authenticated users, and middleware spans. Add end-to-end Lilya integration tests plus generated tox and CI matrix entries. --- .github/workflows/test-integrations-web-2.yml | 4 + CHANGELOG.md | 4 + docs/integrations.rst | 5 + pyproject.toml | 5 + scripts/populate_tox/config.py | 11 + .../split_tox_gh_actions.py | 1 + sentry_sdk/consts.py | 3 + sentry_sdk/integrations/__init__.py | 2 + sentry_sdk/integrations/asgi.py | 74 +- sentry_sdk/integrations/lilya.py | 844 ++++++++++++++ setup.py | 1 + tests/integrations/asgi/test_asgi.py | 46 + tests/integrations/lilya/__init__.py | 3 + tests/integrations/lilya/test_lilya.py | 1011 +++++++++++++++++ tox.ini | 140 +++ uv.lock | 69 ++ 16 files changed, 2191 insertions(+), 32 deletions(-) create mode 100644 sentry_sdk/integrations/lilya.py create mode 100644 tests/integrations/lilya/__init__.py create mode 100644 tests/integrations/lilya/test_lilya.py diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 2e01d62ff9..a110916f9b 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -53,6 +53,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-falcon" + - name: Test lilya + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-lilya" - name: Test litestar run: | set -x # print commands that are executed diff --git a/CHANGELOG.md b/CHANGELOG.md index dca1fedd5d..9809b95827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The SDK now extracts all `gen_ai` spans out of a transaction and sends them as v Self-hosted Sentry users should opt out with `stream_gen_ai_spans=False`, since streamed `gen_ai` spans may not be ingested by their Sentry instance. +### Features + +- Add first-class Lilya framework integration. + ### Bug Fixes 🐛 - (asyncpg) Use distinct span ops for cursor iteration and fetch to prevent N+1 false positives by @ericapisani in [#6609](https://github.com/getsentry/sentry-python/pull/6609) diff --git a/docs/integrations.rst b/docs/integrations.rst index fddf7d038a..2dea8caa95 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -4,6 +4,11 @@ Integrations TBD +Lilya +===== + +.. autoclass:: sentry_sdk.integrations.lilya.LilyaIntegration + Logging ======= diff --git a/pyproject.toml b/pyproject.toml index 356fa4227c..9f0a4bb26e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ typing = [ "mcp>=2.0.0b1", "starlette>=1.3.1", "python-multipart>=0.0.32", + "lilya>=0.27.0", "pydantic>=2.13.4", ] test = [ @@ -197,6 +198,10 @@ ignore_missing_imports = true module = "google.genai.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "lilya.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "executing.*" ignore_missing_imports = true diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index a2640e14d5..307caa3c44 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -276,6 +276,17 @@ "package": "launchdarkly-server-sdk", "num_versions": 2, }, + "lilya": { + "package": "lilya", + "deps": { + "*": [ + "httpx", + "pytest-asyncio", + "anyio>=3,<5", + ], + }, + "python": ">=3.10", + }, "litellm": { "package": "litellm", "deps": { diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index d6b5494515..2d58bae992 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -154,6 +154,7 @@ "asgi", "bottle", "falcon", + "lilya", "litestar", "pyramid", "quart", diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 759898f6ba..1829cfd832 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1222,6 +1222,9 @@ class OP: HTTP_CLIENT_STREAM = "http.client.stream" HTTP_SERVER = "http.server" MIDDLEWARE_DJANGO = "middleware.django" + MIDDLEWARE_LILYA = "middleware.lilya" + MIDDLEWARE_LILYA_RECEIVE = "middleware.lilya.receive" + MIDDLEWARE_LILYA_SEND = "middleware.lilya.send" MIDDLEWARE_LITESTAR = "middleware.litestar" MIDDLEWARE_LITESTAR_RECEIVE = "middleware.litestar.receive" MIDDLEWARE_LITESTAR_SEND = "middleware.litestar.send" diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 677d34a81e..33d7d30193 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -89,6 +89,7 @@ def iter_default_integrations( "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", "sentry_sdk.integrations.langchain.LangchainIntegration", "sentry_sdk.integrations.langgraph.LanggraphIntegration", + "sentry_sdk.integrations.lilya.LilyaIntegration", "sentry_sdk.integrations.litestar.LitestarIntegration", "sentry_sdk.integrations.loguru.LoguruIntegration", "sentry_sdk.integrations.mcp.MCPIntegration", @@ -145,6 +146,7 @@ def iter_default_integrations( "langchain": (0, 1, 0), "langgraph": (0, 6, 6), "launchdarkly": (9, 8, 0), + "lilya": (0, 27, 0), "litellm": (1, 77, 5), "loguru": (0, 7, 0), "mcp": (1, 15, 0), diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 8b1ff5e2a3..4b663582b4 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -466,23 +466,28 @@ def _get_transaction_name_and_source( source = TransactionSource.URL elif transaction_style == "url": - # FastAPI includes the route object in the scope to let Sentry extract the - # path from it for the transaction name - route = asgi_scope.get("route") - if route: - path = getattr(route, "path", None) - if path is not None: - name = path + route_path_template = asgi_scope.get("route_path_template") + if route_path_template is not None: + name = route_path_template else: - name = _get_url( - asgi_scope, - "http" if ty == "http" else "ws", - host=None, - path=_get_path( - asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path - ), - ) - source = TransactionSource.URL + # FastAPI includes the route object in the scope to let Sentry extract the + # path from it for the transaction name + route = asgi_scope.get("route") + if route: + path = getattr(route, "path", None) + if path is not None: + name = path + else: + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path=_get_path( + asgi_scope=asgi_scope, + root_path_in_path=self.root_path_in_path, + ), + ) + source = TransactionSource.URL if name is None: name = _DEFAULT_TRANSACTION_NAME @@ -517,23 +522,28 @@ def _get_segment_name_and_source( source = SegmentSource.URL.value elif segment_style == "url": - # FastAPI includes the route object in the scope to let Sentry extract the - # path from it for the transaction name - route = asgi_scope.get("route") - if route: - path = getattr(route, "path", None) - if path is not None: - name = path + route_path_template = asgi_scope.get("route_path_template") + if route_path_template is not None: + name = route_path_template else: - name = _get_url( - asgi_scope, - "http" if ty == "http" else "ws", - host=None, - path=_get_path( - asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path - ), - ) - source = SegmentSource.URL.value + # FastAPI includes the route object in the scope to let Sentry extract the + # path from it for the transaction name + route = asgi_scope.get("route") + if route: + path = getattr(route, "path", None) + if path is not None: + name = path + else: + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path=_get_path( + asgi_scope=asgi_scope, + root_path_in_path=self.root_path_in_path, + ), + ) + source = SegmentSource.URL.value if name is None: name = _DEFAULT_TRANSACTION_NAME diff --git a/sentry_sdk/integrations/lilya.py b/sentry_sdk/integrations/lilya.py new file mode 100644 index 0000000000..fc395febed --- /dev/null +++ b/sentry_sdk/integrations/lilya.py @@ -0,0 +1,844 @@ +import functools +import json +import sys +import warnings +from collections.abc import Mapping as MappingABC +from collections.abc import MutableMapping as MutableMappingABC +from collections.abc import Set +from copy import deepcopy +from json import JSONDecodeError +from types import FunctionType +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk._types import OVER_SIZE_LIMIT_SUBSTITUTE +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import ( + _DEFAULT_FAILED_REQUEST_STATUS_CODES, + DidNotEnable, + Integration, +) +from sentry_sdk.integrations._asgi_common import _RootPathInPath +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + HttpCodeRangeContainer, + _is_json_content_type, + request_body_within_bounds, +) +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.traces import StreamedSpan, get_current_span +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled +from sentry_sdk.utils import ( + AnnotatedValue, + capture_internal_exceptions, + event_from_exception, + parse_version, + transaction_from_function, +) + +if TYPE_CHECKING: + from typing import ( + Any, + Awaitable, + Callable, + Container, + Dict, + Mapping, + MutableMapping, + Optional, + Tuple, + Union, + ) + + from sentry_sdk._types import Event, Hint, HttpStatusCodeRange + +try: + from lilya import __version__ as LILYA_VERSION +except ImportError: + raise DidNotEnable("Lilya is not installed") + +version = parse_version(LILYA_VERSION) + +if version is None: + raise DidNotEnable("Unparsable Lilya version: {}".format(LILYA_VERSION)) + +if version < (0, 27, 0): + raise DidNotEnable("Lilya 0.27.0 or newer is required") + +try: + from lilya._internal import _exception_handlers as exception_handlers_module + from lilya._internal import _responses as responses_module + from lilya._internal._responses import BaseHandler + from lilya.apps import Lilya + from lilya.concurrency import run_in_threadpool + from lilya.datastructures import DataUpload + from lilya.middleware import exceptions as middleware_exceptions_module + from lilya.middleware.authentication import AuthenticationMiddleware + from lilya.middleware.base import DefineMiddleware + from lilya.middleware.exceptions import ExceptionMiddleware + from lilya.requests import Request + from lilya.types import Empty +except (ImportError, SyntaxError): + raise DidNotEnable("Lilya is not installed") + + +if sys.version_info >= (3, 14): + from inspect import iscoroutinefunction +else: + from asyncio import iscoroutinefunction + + +_DEFAULT_TRANSACTION_NAME = "generic Lilya request" + +TRANSACTION_STYLE_VALUES = ("endpoint", "url") + + +class LilyaIntegration(Integration): + """Adds Sentry instrumentation for Lilya applications.""" + + identifier = "lilya" + origin = "auto.http.lilya" + + transaction_style = "" + + def __init__( + self, + transaction_style: str = "url", + failed_request_status_codes: "Union[Set[int], list[HttpStatusCodeRange], None]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES, + middleware_spans: bool = False, + http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + ) -> None: + if transaction_style not in TRANSACTION_STYLE_VALUES: + raise ValueError( + "Invalid value for transaction_style: %s (must be in %s)" + % (transaction_style, TRANSACTION_STYLE_VALUES) + ) + + self.transaction_style = transaction_style + self.middleware_spans = middleware_spans + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) + + if isinstance(failed_request_status_codes, Set): + self.failed_request_status_codes: "Container[int]" = ( + failed_request_status_codes + ) + else: + warnings.warn( + "Passing a list or None for failed_request_status_codes is deprecated. " + "Please pass a set of int instead.", + DeprecationWarning, + stacklevel=2, + ) + + if failed_request_status_codes is None: + self.failed_request_status_codes = _DEFAULT_FAILED_REQUEST_STATUS_CODES + else: + self.failed_request_status_codes = HttpCodeRangeContainer( + failed_request_status_codes + ) + + @staticmethod + def setup_once() -> None: + """Install Lilya patches for request, middleware, auth, and error paths.""" + patch_asgi_app() + patch_middlewares() + patch_authentication_middleware() + patch_exception_middleware() + patch_handle_exception() + patch_handle_response() + + +def patch_asgi_app() -> None: + """Wrap Lilya applications so every app instance is traced by default.""" + old_app = Lilya.__call__ + + async def _sentry_patched_asgi_app( + self: "Any", + scope: "MutableMapping[str, Any]", + receive: "Callable[[], Awaitable[MutableMapping[str, Any]]]", + send: "Callable[[MutableMapping[str, Any]], Awaitable[None]]", + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(LilyaIntegration) + if integration is None: + return await old_app(self, scope, receive, send) + + middleware = SentryAsgiMiddleware( + lambda *a, **kw: old_app(self, *a, **kw), + mechanism_type=LilyaIntegration.identifier, + transaction_style=integration.transaction_style, + span_origin=LilyaIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), + asgi_version=3, + root_path_in_path=_RootPathInPath.EITHER, + ) + + return await middleware(scope, receive, send) + + Lilya.__call__ = _sentry_patched_asgi_app # type: ignore[method-assign] + + +def _is_async_callable(obj: "Any") -> bool: + """Return whether an object is callable through an async call path.""" + while isinstance(obj, functools.partial): + obj = obj.func + + return iscoroutinefunction(obj) or ( + callable(obj) and iscoroutinefunction(obj.__call__) # type: ignore[operator] + ) + + +def _get_route_path_template(scope: "MutableMapping[str, Any]") -> "Optional[str]": + """Return Lilya's routed path template, including mounted application prefixes.""" + template = scope.get("route_path_template") + if template is None: + route = scope.get("route") + if route is not None: + template = getattr(route, "path", None) + + if template is None: + return None + + app_root_path = scope.get("app_root_path") or "" + root_path = scope.get("root_path") or "" + mount_prefix = "" + + if root_path: + if app_root_path and root_path.startswith(app_root_path): + mount_prefix = root_path[len(app_root_path) :] + elif not app_root_path: + mount_prefix = root_path + + if mount_prefix: + template = "%s/%s" % (mount_prefix.rstrip("/"), template.lstrip("/")) + + if not template.startswith("/"): + template = "/%s" % template + + return template + + +def _get_transaction_name_and_source( + transaction_style: str, + scope: "MutableMapping[str, Any]", +) -> "Tuple[str, TransactionSource]": + """Resolve the Sentry transaction name from Lilya's ASGI scope.""" + if transaction_style == "endpoint": + endpoint = scope.get("handler") + if endpoint is not None: + name = transaction_from_function(endpoint) + if name: + return name, TransactionSource.COMPONENT + + template = _get_route_path_template(scope) + if template is not None: + return template, TransactionSource.ROUTE + + return _DEFAULT_TRANSACTION_NAME, SOURCE_FOR_STYLE[transaction_style] + + +def _has_lilya_transaction_data( + transaction_style: str, + scope: "MutableMapping[str, Any]", +) -> bool: + """Return whether Lilya has populated route data on the scope.""" + if transaction_style == "endpoint" and scope.get("handler") is not None: + return True + + return _get_route_path_template(scope) is not None + + +def _set_transaction_name_and_source( + scope: "MutableMapping[str, Any]", + transaction_style: str, +) -> None: + """Set transaction naming on the current and isolation scopes.""" + name, source = _get_transaction_name_and_source(transaction_style, scope) + sentry_sdk.set_transaction_name(name, source) + sentry_sdk.get_isolation_scope().set_transaction_name(name, source) + + +def _get_cached_request_body_attribute( + client: "Any", + request: "Request", +) -> "Optional[str]": + """Return cached request body data suitable for span attributes.""" + if "content-length" not in request.headers: + return None + + try: + content_length = int(request.headers["content-length"]) + except ValueError: + return None + + if content_length and not request_body_within_bounds(client, content_length): + return OVER_SIZE_LIMIT_SUBSTITUTE + + json_body = getattr(request, "_json", Empty) + if json_body is not Empty: + return json.dumps(json_body) + + formdata_body: "Any" = getattr(request, "_form", Empty) + if formdata_body is Empty or formdata_body is None: + return None + + form_data: "Dict[str, Any]" = {} + for key, val in formdata_body.items(): + is_file = isinstance(val, DataUpload) + form_data[key] = val if not is_file else "[Unparsable]" + + return json.dumps(form_data) + + +def patch_handle_response() -> None: + """Wrap Lilya endpoint adapters to attach request data and handled errors. + + Lilya builds an ASGI app from each handler through `BaseHandler`. Patching + that adapter lets the integration name route transactions, preserve the + endpoint call path, and capture configured handled HTTP exceptions without + changing the user's handler signature. + """ + old_handle_response = BaseHandler.handle_response + + def _sentry_handle_response( + self: "Any", + func: "Callable[..., Any]", + other_signature: "Any" = None, + ) -> "Callable[..., Awaitable[Any]]": + if _is_async_callable(func): + + @functools.wraps(func) + async def _sentry_lilya_endpoint(*args: "Any", **kwargs: "Any") -> "Any": + try: + return await func(*args, **kwargs) + except Exception as exc: + _capture_handled_status_exception(exc) + raise + + else: + + @functools.wraps(func) + def _sentry_lilya_endpoint(*args: "Any", **kwargs: "Any") -> "Any": + _update_active_thread() + try: + return func(*args, **kwargs) + except Exception as exc: + _capture_handled_status_exception(exc) + raise + + handler_app = old_handle_response(self, _sentry_lilya_endpoint, other_signature) + + async def _sentry_lilya_handler_app( + scope: "MutableMapping[str, Any]", + receive: "Callable[[], Awaitable[MutableMapping[str, Any]]]", + send: "Callable[[MutableMapping[str, Any]], Awaitable[None]]", + ) -> "Any": + client = sentry_sdk.get_client() + integration = client.get_integration(LilyaIntegration) + + if integration is None or scope.get("type") != "http": + return await handler_app(scope, receive, send) + + request = Request(scope, receive, send) + _set_transaction_name_and_source(scope, integration.transaction_style) + + sentry_scope = sentry_sdk.get_isolation_scope() + extractor = LilyaRequestExtractor(request) + info = await extractor.extract_request_info() + exception_handlers = scope.get("lilya.exception_handlers") + _wrap_exception_handler_maps(exception_handlers) + + def event_processor(event: "Event", _hint: "Hint") -> "Event": + request_info = event.get("request", {}) + if info: + if "cookies" in info: + request_info["cookies"] = info["cookies"] + if "data" in info: + request_info["data"] = info["data"] + event["request"] = deepcopy(request_info) + + return event + + sentry_scope._name = LilyaIntegration.identifier + sentry_scope.add_event_processor(event_processor) + current_span = get_current_span() + + try: + return await handler_app(scope, receive, send) + finally: + if type(current_span) is StreamedSpan: + body = _get_cached_request_body_attribute(client, request) + if body is not None: + current_span._segment.set_attribute( + SPANDATA.HTTP_REQUEST_BODY_DATA, body + ) + + return _sentry_lilya_handler_app + + BaseHandler.handle_response = _sentry_handle_response # type: ignore[method-assign] + + +def patch_middlewares() -> None: + """Patch middleware definitions so configured user middleware emits spans.""" + old_middleware_init = DefineMiddleware.__init__ + + not_yet_patched = "_sentry_middleware_init" not in str(old_middleware_init) + if not_yet_patched: + + def _sentry_middleware_init( + self: "Any", cls: "Any", *args: "Any", **kwargs: "Any" + ) -> None: + old_middleware_init(self, cls, *args, **kwargs) + + with capture_internal_exceptions(): + middleware_class = self.middleware + if middleware_class is SentryAsgiMiddleware: + return + _enable_span_for_middleware(middleware_class) + + DefineMiddleware.__init__ = _sentry_middleware_init # type: ignore[method-assign] + + +def _enable_span_for_middleware(middleware_class: "Any") -> None: + """Wrap a Lilya middleware call with middleware, receive, and send spans.""" + old_call = middleware_class.__call__ + + async def _create_span_call( + self: "Any", + scope: "MutableMapping[str, Any]", + receive: "Callable[..., Awaitable[MutableMapping[str, Any]]]", + send: "Callable[[MutableMapping[str, Any]], Awaitable[None]]", + ) -> "Any": + client = sentry_sdk.get_client() + integration = client.get_integration(LilyaIntegration) + if integration is None or not integration.middleware_spans: + return await old_call(self, scope, receive, send) + + if _has_lilya_transaction_data(integration.transaction_style, scope): + _set_transaction_name_and_source(scope, integration.transaction_style) + + middleware_name = self.__class__.__name__ + if has_span_streaming_enabled(client.options): + with sentry_sdk.traces.start_span( + name=middleware_name, + attributes={ + "sentry.op": OP.MIDDLEWARE_LILYA, + "sentry.origin": LilyaIntegration.origin, + }, + ) as middleware_span: + middleware_span.set_attribute(SPANDATA.MIDDLEWARE_NAME, middleware_name) + + async def _sentry_receive( + *args: "Any", **kwargs: "Any" + ) -> "MutableMapping[str, Any]": + if client.get_integration(LilyaIntegration) is None: + return await receive(*args, **kwargs) + with sentry_sdk.traces.start_span( + name=getattr(receive, "__qualname__", str(receive)), + attributes={ + "sentry.op": OP.MIDDLEWARE_LILYA_RECEIVE, + "sentry.origin": LilyaIntegration.origin, + }, + ) as span: + span.set_attribute(SPANDATA.MIDDLEWARE_NAME, middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + async def _sentry_send(message: "MutableMapping[str, Any]") -> None: + if client.get_integration(LilyaIntegration) is None: + return await send(message) + with sentry_sdk.traces.start_span( + name=getattr(send, "__qualname__", str(send)), + attributes={ + "sentry.op": OP.MIDDLEWARE_LILYA_SEND, + "sentry.origin": LilyaIntegration.origin, + }, + ) as span: + span.set_attribute(SPANDATA.MIDDLEWARE_NAME, middleware_name) + return await send(message) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(self, scope, new_receive, new_send) + + else: + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LILYA, + name=middleware_name, + origin=LilyaIntegration.origin, + ) as middleware_span: + middleware_span.set_tag("lilya.middleware_name", middleware_name) + + async def _sentry_receive( + *args: "Any", **kwargs: "Any" + ) -> "MutableMapping[str, Any]": + if client.get_integration(LilyaIntegration) is None: + return await receive(*args, **kwargs) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LILYA_RECEIVE, + name=getattr(receive, "__qualname__", str(receive)), + origin=LilyaIntegration.origin, + ) as span: + span.set_tag("lilya.middleware_name", middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + async def _sentry_send(message: "MutableMapping[str, Any]") -> None: + if client.get_integration(LilyaIntegration) is None: + return await send(message) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LILYA_SEND, + name=getattr(send, "__qualname__", str(send)), + origin=LilyaIntegration.origin, + ) as span: + span.set_tag("lilya.middleware_name", middleware_name) + return await send(message) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(self, scope, new_receive, new_send) + + not_yet_patched = old_call.__name__ not in ["_create_span_call"] + + if not_yet_patched: + middleware_class.__call__ = _create_span_call + + +def patch_authentication_middleware() -> None: + """Patch authentication so downstream events can include Lilya's user.""" + old_authenticate = AuthenticationMiddleware.authenticate + + async def _sentry_patched_authenticate( + self: "Any", + conn: "Any", + **kwargs: "Any", + ) -> "Any": + auth_result = await old_authenticate(self, conn, **kwargs) + if isinstance(auth_result, tuple) and len(auth_result) > 1: + _set_user_to_sentry_scope(auth_result[1]) + + return auth_result + + not_yet_patched = old_authenticate.__name__ != "_sentry_patched_authenticate" + if not_yet_patched: + AuthenticationMiddleware.authenticate = _sentry_patched_authenticate # type: ignore[method-assign] + + +def patch_exception_middleware() -> None: + """Patch exception middleware so handled errors keep Sentry context.""" + old_init = ExceptionMiddleware.__init__ + old_call = ExceptionMiddleware.__call__ + + def _sentry_patched_exception_init( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> None: + old_init(self, *args, **kwargs) + _wrap_exception_handlers(self._exception_handlers) + _wrap_exception_handlers(self._status_handlers) + + async def _sentry_patched_exception_call( + self: "Any", + scope: "MutableMapping[str, Any]", + receive: "Callable[[], Awaitable[MutableMapping[str, Any]]]", + send: "Callable[[MutableMapping[str, Any]], Awaitable[None]]", + ) -> "Any": + _add_user_to_sentry_scope(scope) + return await old_call(self, scope, receive, send) + + not_yet_patched = old_init.__name__ != "_sentry_patched_exception_init" + if not_yet_patched: + ExceptionMiddleware.__init__ = _sentry_patched_exception_init # type: ignore[method-assign] + + not_yet_call_patched = old_call.__name__ != "_sentry_patched_exception_call" + if not_yet_call_patched: + ExceptionMiddleware.__call__ = _sentry_patched_exception_call # type: ignore[method-assign] + + +def patch_handle_exception() -> None: + """Patch Lilya's shared handled-exception dispatcher as a fallback path.""" + patched: "set[int]" = set() + for module in ( + exception_handlers_module, + responses_module, + middleware_exceptions_module, + ): + handle_exception = getattr(module, "handle_exception", None) + if ( + not isinstance(handle_exception, FunctionType) + or id(handle_exception) in patched + ): + continue + patched.add(id(handle_exception)) + _patch_handle_exception_function(handle_exception) + + +def _copy_function(func: FunctionType) -> FunctionType: + """Copy a Lilya function before patching the original in-place.""" + copied = FunctionType( + func.__code__, + func.__globals__, + name=func.__name__, + argdefs=func.__defaults__, + closure=func.__closure__, + ) + copied.__kwdefaults__ = func.__kwdefaults__ + copied.__dict__.update(func.__dict__) + copied.__annotations__ = dict(func.__annotations__) + return copied + + +def _patch_handle_exception_function(handle_exception: FunctionType) -> None: + """Patch a Lilya handled-exception dispatcher without replacing it.""" + if handle_exception.__name__ == "_sentry_patched_handle_exception": + return + + old_handle_exception = _copy_function(handle_exception) + + async def _sentry_patched_handle_exception( + scope: "MutableMapping[str, Any]", + conn: "Any", + exc: Exception, + handler: "Callable[..., Any]", + _old_handle_exception: FunctionType = old_handle_exception, + _capture: "Callable[[Exception], None]" = _capture_handled_status_exception, + ) -> "Any": + _capture(exc) + + return await _old_handle_exception(scope, conn, exc, handler) + + handle_exception.__code__ = _sentry_patched_handle_exception.__code__ + handle_exception.__defaults__ = _sentry_patched_handle_exception.__defaults__ + handle_exception.__kwdefaults__ = _sentry_patched_handle_exception.__kwdefaults__ + handle_exception.__name__ = "_sentry_patched_handle_exception" + handle_exception.__qualname__ = "_sentry_patched_handle_exception" + handle_exception.__annotations__ = dict( + _sentry_patched_handle_exception.__annotations__ + ) + + +def _wrap_exception_handler_maps(handler_maps: "Any") -> None: + """Wrap the exception handler maps Lilya stores on the ASGI scope.""" + if not handler_maps: + return + + if isinstance(handler_maps, MutableMappingABC): + _wrap_exception_handlers(handler_maps) + return + + if not isinstance(handler_maps, (list, tuple)): + return + + for handler_map in handler_maps: + if isinstance(handler_map, MutableMappingABC): + _wrap_exception_handlers(handler_map) + + +def _wrap_exception_handlers( + handler_map: "MutableMapping[Any, Callable[..., Any]]", +) -> None: + """Wrap Lilya exception and status handlers in-place.""" + for key, handler in list(handler_map.items()): + if getattr(handler, "__name__", None) == "_sentry_lilya_exception_handler": + continue + handler_map[key] = _wrap_exception_handler(handler) + + +def _wrap_exception_handler( + handler: "Callable[..., Any]", +) -> "Callable[[Any, Exception], Awaitable[Any]]": + """Create a Sentry-aware Lilya exception handler.""" + + async def _sentry_lilya_exception_handler(request: "Any", exc: Exception) -> "Any": + _capture_handled_status_exception(exc) + + if _is_async_callable(handler): + return await handler(request, exc) + + return await run_in_threadpool(handler, request, exc) + + return _sentry_lilya_exception_handler + + +def _update_active_thread() -> None: + """Set active transaction and profile thread data for sync Lilya handlers.""" + client = sentry_sdk.get_client() + if client.get_integration(LilyaIntegration) is None: + return + + current_scope = sentry_sdk.get_current_scope() + if has_span_streaming_enabled(client.options): + current_span = current_scope.streamed_span + if type(current_span) is StreamedSpan: + current_span._segment._update_active_thread() + elif current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() + + +def _capture_exception(exc: BaseException, handled: bool = False) -> None: + """Capture an exception using Lilya's Sentry mechanism metadata.""" + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": LilyaIntegration.identifier, "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _capture_handled_status_exception(exc: Exception) -> None: + """Capture handled Lilya HTTP exceptions configured as failed requests.""" + if getattr(exc, "_sentry_lilya_captured", False): + return + + integration = sentry_sdk.get_client().get_integration(LilyaIntegration) + if ( + integration is not None + and getattr(exc, "status_code", None) in integration.failed_request_status_codes + ): + setattr(exc, "_sentry_lilya_captured", True) + _capture_exception(exc, handled=True) + + +def _add_user_to_sentry_scope(scope: "MutableMapping[str, Any]") -> None: + """Copy Lilya's authenticated user from the ASGI scope into Sentry.""" + _set_user_to_sentry_scope(scope.get("user")) + + +def _set_user_to_sentry_scope(scope_user: "Any") -> None: + """Copy a Lilya user object into Sentry's standard user fields.""" + if not should_send_default_pii(): + return + + user = _retrieve_user_from_scope_user(scope_user) + if user is None: + return + + sentry_sdk.get_isolation_scope().set_user(user) + + +def _retrieve_user_from_scope_user(scope_user: "Any") -> "Optional[Dict[str, Any]]": + """Extract Sentry user fields from a Lilya scope user.""" + if not scope_user: + return None + + if isinstance(scope_user, MappingABC): + return _select_sentry_user_fields(scope_user) + + user_info: "Dict[str, Any]" = {} + + if hasattr(scope_user, "asdict"): + asdict_user = scope_user.asdict() + if isinstance(asdict_user, MappingABC): + user_info.update(_select_sentry_user_fields(asdict_user)) + + user_id = getattr(scope_user, "unique_identifier", None) + if user_id: + user_info["id"] = str(user_id) + + username = getattr(scope_user, "display_name", None) or getattr( + scope_user, "username", None + ) + if username: + user_info["username"] = str(username) + + email = getattr(scope_user, "email", None) + if email: + user_info["email"] = str(email) + + return user_info or None + + +def _select_sentry_user_fields(user_info: "Mapping[str, Any]") -> "Dict[str, Any]": + """Return only fields supported by Sentry's user payload.""" + return { + key: str(user_info[key]) + for key in ("id", "username", "email") + if user_info.get(key) is not None and user_info.get(key) != "" + } + + +class LilyaRequestExtractor: + """Extracts Lilya request data for Sentry events.""" + + def __init__(self, request: "Request") -> None: + self.request = request + + async def extract_request_info(self) -> "Dict[str, Any]": + """Return request body and cookie data collected without consuming handlers.""" + client = sentry_sdk.get_client() + + request_info: "Dict[str, Any]" = {} + + with capture_internal_exceptions(): + if should_send_default_pii(): + request_info["cookies"] = self.cookies() + + content_length = await self.content_length() + if not content_length: + return request_info + + if content_length and not request_body_within_bounds( + client, content_length + ): + request_info["data"] = AnnotatedValue.removed_because_over_size_limit() + return request_info + + if self.is_form(): + return request_info + + json_body = await self.json() + if json_body is not Empty: + request_info["data"] = json_body + return request_info + + request_info["data"] = AnnotatedValue.removed_because_raw_data() + return request_info + + async def content_length(self) -> "Optional[int]": + if "content-length" in self.request.headers: + try: + return int(self.request.headers["content-length"]) + except ValueError: + return None + + return None + + def cookies(self) -> "Dict[str, Any]": + return self.request.cookies + + def is_form(self) -> bool: + content_type = self.request.headers.get("content-type") + media_type = (content_type or "").split(";", 1)[0].lower() + return media_type in ( + "multipart/form-data", + "application/x-www-form-urlencoded", + ) + + def is_json(self) -> bool: + return _is_json_content_type(self.request.headers.get("content-type")) + + async def json(self) -> "Any": + if not self.is_json(): + return Empty + try: + return await self.request.json() + except JSONDecodeError: + return Empty diff --git a/setup.py b/setup.py index c18b6197f9..bab5f27537 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,7 @@ def get_file_text(file_name): "langchain": ["langchain>=0.0.210"], "langgraph": ["langgraph>=0.6.6"], "launchdarkly": ["launchdarkly-server-sdk>=9.8.0"], + "lilya": ["lilya>=0.27.0"], "litellm": ["litellm>=1.77.5,!=1.82.7,!=1.82.8"], "litestar": ["litestar>=2.0.0"], "loguru": ["loguru>=0.5"], diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 3bce0d1e10..ee077441b3 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -1,4 +1,5 @@ from collections import Counter +from types import SimpleNamespace import pytest from async_asgi_testclient import TestClient @@ -687,6 +688,51 @@ async def test_transaction_style( assert transaction_event["transaction_info"] == {"source": expected_source} +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) +@pytest.mark.asyncio +async def test_route_path_template_preferred_over_route_path( + sentry_init, + asgi3_app, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + app = SentryAsgiMiddleware(asgi3_app, transaction_style="url") + scope = { + "endpoint": asgi3_app, + "route": SimpleNamespace(path="/route-object/{value}"), + "route_path_template": "/scope-template/{value}", + "client": ("127.0.0.1", 60457), + } + + async with TestClient(app, scope=scope) as client: + if span_streaming: + items = capture_items("span") + else: + events = capture_events() + await client.get("/scope-template/123") + + sentry_sdk.flush() + + if span_streaming: + (span,) = [item.payload for item in items] + assert span["name"] == "/scope-template/{value}" + assert span["attributes"]["sentry.span.source"] == "route" + else: + (transaction_event,) = events + assert transaction_event["transaction"] == "/scope-template/{value}" + assert transaction_event["transaction_info"] == {"source": "route"} + + def mock_asgi2_app(): pass diff --git a/tests/integrations/lilya/__init__.py b/tests/integrations/lilya/__init__.py new file mode 100644 index 0000000000..c983bea330 --- /dev/null +++ b/tests/integrations/lilya/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("lilya") diff --git a/tests/integrations/lilya/test_lilya.py b/tests/integrations/lilya/test_lilya.py new file mode 100644 index 0000000000..a84c1f41fb --- /dev/null +++ b/tests/integrations/lilya/test_lilya.py @@ -0,0 +1,1011 @@ +import asyncio +import base64 +import functools +import json +import threading +from unittest import mock + +import pytest +from lilya._internal._exception_handlers import ( + handle_exception as _direct_handle_exception, +) +from lilya.apps import Lilya +from lilya.authentication import AuthCredentials, AuthenticationBackend, BasicUser +from lilya.background import Task +from lilya.exceptions import AuthenticationError, HTTPException +from lilya.middleware import DefineMiddleware +from lilya.middleware.authentication import AuthenticationMiddleware +from lilya.requests import Request +from lilya.responses import JSONResponse, PlainText, Response, StreamingResponse +from lilya.routing import Include, Path, WebSocketPath +from lilya.testclient import TestClient + +import sentry_sdk +from sentry_sdk import capture_message +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.integrations.lilya import LilyaIntegration, _wrap_exception_handler_maps +from tests.conftest import ApproxDict +from tests.integrations.conftest import parametrize_test_configurable_status_codes + + +def _transaction_from_events(events): + transactions = [event for event in events if event.get("type") == "transaction"] + assert len(transactions) == 1 + return transactions[0] + + +def _message_from_events(events, message="hi"): + messages = [ + event + for event in events + if event.get("message") == message + or event.get("logentry", {}).get("message") == message + ] + assert len(messages) == 1 + return messages[0] + + +class BasicAuth(AuthenticationBackend): + async def authenticate(self, conn): + if "Authorization" not in conn.headers: + return None + + scheme, credentials = conn.headers["Authorization"].split() + assert scheme.lower() == "basic" + username, _, _ = base64.b64decode(credentials).decode("ascii").partition(":") + user = BasicUser(username) + user.id = "user-1" + user.email = "%s@example.com" % username + return AuthCredentials(["authenticated"]), user + + +class FailingAuth(AuthenticationBackend): + async def authenticate(self, conn): + raise AuthenticationError("authentication failed") + + +class RichUser: + unique_identifier = "user-1" + display_name = "lilya" + email = "lilya@example.com" + + def asdict(self): + return { + "id": "user-1", + "username": "lilya", + "email": "lilya@example.com", + "is_admin": True, + "token": "secret", + } + + +class RichUserAuth(AuthenticationBackend): + async def authenticate(self, conn): + return AuthCredentials(["authenticated"]), RichUser() + + +class SampleMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + await self.app(scope, receive, send) + + +class ReceiveSendMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + message = await receive() + assert message["type"] == "http.request" + + send_output = await send({"type": "something-unimportant"}) + assert send_output is None + + await self.app(scope, receive, send) + + +class PartialReceiveSendMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + message = await receive() + assert message["type"] == "http.request" + + send_output = await send({"type": "something-unimportant"}) + assert send_output is None + + async def my_receive(*args, **kwargs): + return await receive(*args, **kwargs) + + async def my_send(*args, **kwargs): + return await send(*args, **kwargs) + + await self.app(scope, functools.partial(my_receive), functools.partial(my_send)) + + +class RaisingMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + raise HTTPException(status_code=500, detail="middleware error") + await self.app(scope, receive, send) + + +def lilya_app_factory(middleware=None, exception_handlers=None, lifespan=None): + background_calls = [] + + async def error(): + 1 / 0 + + async def message(): + capture_message("hi") + return {"status": "ok"} + + async def message_with_id(message_id): + capture_message("hi") + return {"message_id": message_id} + + def thread_ids_sync(): + return { + "main": threading.main_thread().ident, + "active": threading.current_thread().ident, + } + + async def thread_ids_async(): + return { + "main": threading.main_thread().ident, + "active": threading.current_thread().ident, + } + + async def handled_http_error(): + raise HTTPException(status_code=500, detail="handled") + + async def bad_request(): + raise HTTPException(status_code=400, detail="bad request") + + async def custom_error(): + raise ValueError("custom") + + async def json_body(request): + data = await request.json() + assert data == {"username": "Jane", "password": "secret"} + capture_message("json body") + return data + + async def json_echo(request): + await request.json() + capture_message("json echo") + return {"status": "ok"} + + async def form_body(request): + form = await request.form() + assert form["username"] == "Jane" + assert form["password"] == "secret" + capture_message("form body") + return {"username": form["username"]} + + async def stream_body(request): + body = b"" + async for chunk in request.stream(): + body += chunk + return PlainText(body.decode("utf-8")) + + async def streaming_response(): + async def generator(): + yield "hello" + yield " " + yield "world" + + return StreamingResponse(generator(), media_type="text/plain") + + async def background_response(): + async def background_task(): + background_calls.append("done") + + return Response("ok", background=Task(background_task)) + + async def auth_message(request): + capture_message("auth message") + return {"user": request.user.display_name} + + async def websocket_endpoint(websocket): + await websocket.accept() + message = await websocket.receive_text() + await websocket.send_text("echo:%s" % message) + await websocket.close() + + async def child_message(): + capture_message("hi") + return {"status": "ok"} + + child = Lilya(routes=[Path("/message/{message_id}", child_message)]) + + routes = [ + Path("/some_url", error), + Path("/message", message), + Path("/message/{message_id}", message_with_id), + Path("/sync/thread_ids", thread_ids_sync), + Path("/async/thread_ids", thread_ids_async), + Path("/handled-http", handled_http_error), + Path("/bad-request", bad_request), + Path("/custom-error", custom_error), + Path("/body/json", json_body, methods=["POST"]), + Path("/body/json/echo", json_echo, methods=["POST"]), + Path("/body/form", form_body, methods=["POST"]), + Path("/body/stream", stream_body, methods=["POST"]), + Path("/streaming-response", streaming_response), + Path("/background", background_response), + Path("/auth-message", auth_message), + WebSocketPath("/ws/{room}", websocket_endpoint), + Include("/root", app=child), + ] + + app = Lilya( + routes=routes, + middleware=middleware or [], + exception_handlers=exception_handlers, + lifespan=lifespan, + ) + app.background_calls = background_calls + return app + + +def test_invalid_transaction_style(): + with pytest.raises(ValueError) as exc: + LilyaIntegration(transaction_style="route") + + assert ( + str(exc.value) + == "Invalid value for transaction_style: route (must be in ('endpoint', 'url'))" + ) + + +@pytest.mark.parametrize( + "transaction_style,expected_source,expected_name", + [ + ("url", "route", "/message/{message_id}"), + ( + "endpoint", + "component", + "tests.integrations.lilya.test_lilya.lilya_app_factory..message_with_id", + ), + ], +) +def test_transaction_name_and_source( + sentry_init, + capture_events, + transaction_style, + expected_source, + expected_name, +): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(transaction_style=transaction_style)], + ) + events = capture_events() + + client = TestClient(lilya_app_factory()) + response = client.get("/message/123") + + assert response.status_code == 200 + transaction = _transaction_from_events(events) + assert transaction["transaction"] == expected_name + assert transaction["transaction_info"] == {"source": expected_source} + + +def test_mounted_route_transaction_name(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(transaction_style="url")], + ) + events = capture_events() + + client = TestClient(lilya_app_factory()) + response = client.get("/root/message/123") + + assert response.status_code == 200 + transaction = _transaction_from_events(events) + assert transaction["transaction"] == "/root/message/{message_id}" + assert transaction["transaction_info"] == {"source": "route"} + + +def test_manual_asgi_middleware_uses_lilya_route_template(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0, default_integrations=False) + events = capture_events() + + app = SentryAsgiMiddleware(lilya_app_factory(), transaction_style="url") + client = TestClient(app) + response = client.get("/message/123") + + assert response.status_code == 200 + transaction = _transaction_from_events(events) + assert transaction["transaction"] == "/message/{message_id}" + assert transaction["transaction_info"] == {"source": "route"} + + +@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) +def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): + sentry_init( + integrations=[LilyaIntegration()], + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + client = TestClient(lilya_app_factory()) + response = client.get(endpoint) + + assert response.status_code == 200 + data = response.json() + + envelopes = [envelope for envelope in envelopes] + assert len(envelopes) == 1 + + profiles = [item for item in envelopes[0].items if item.type == "profile"] + assert len(profiles) == 1 + for item in profiles: + transactions = item.payload.json["transactions"] + assert len(transactions) == 1 + assert str(data["active"]) == transactions[0]["active_thread_id"] + + transactions = [item for item in envelopes[0].items if item.type == "transaction"] + assert len(transactions) == 1 + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + assert str(data["active"]) == trace_context["data"]["thread.id"] + + +@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): + sentry_init( + auto_enabling_integrations=False, + integrations=[LilyaIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + client = TestClient(lilya_app_factory()) + response = client.get(endpoint) + + assert response.status_code == 200 + data = response.json() + + sentry_sdk.flush() + + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + assert str(data["active"]) == segments[0]["attributes"]["thread.id"] + + +def test_catch_unhandled_exception(sentry_init, capture_events, capture_exceptions): + sentry_init(integrations=[LilyaIntegration()]) + events = capture_events() + exceptions = capture_exceptions() + + client = TestClient(lilya_app_factory()) + with pytest.raises(ZeroDivisionError): + client.get("/some_url") + + (exception,) = exceptions + assert isinstance(exception, ZeroDivisionError) + + (event,) = events + assert event["exception"]["values"][0]["mechanism"] == { + "type": "lilya", + "handled": False, + } + assert event["transaction"] == "/some_url" + + +@parametrize_test_configurable_status_codes +def test_handled_http_exception_configurable_status_codes( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + async def endpoint(): + raise HTTPException(status_code=status_code, detail="handled") + + sentry_init( + integrations=[ + LilyaIntegration(failed_request_status_codes=failed_request_status_codes) + ], + ) + events = capture_events() + + app = Lilya(routes=[Path("/", endpoint)]) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/") + + assert response.status_code == status_code + if expected_error: + (event,) = events + assert event["exception"]["values"][0]["mechanism"] == { + "type": "lilya", + "handled": True, + } + else: + assert events == [] + + +def test_custom_exception_handler_is_preserved(sentry_init, capture_events): + async def value_error_handler(request, exc): + return JSONResponse({"error": str(exc)}, status_code=418) + + sentry_init(integrations=[LilyaIntegration()]) + events = capture_events() + + app = lilya_app_factory(exception_handlers={ValueError: value_error_handler}) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/custom-error") + + assert response.status_code == 418 + assert response.json() == {"error": "custom"} + assert events == [] + + +def test_exception_handler_scope_maps_can_have_single_entry(): + async def value_error_handler(request, exc): + return JSONResponse({"error": str(exc)}, status_code=418) + + handler_maps = ({ValueError: value_error_handler},) + + _wrap_exception_handler_maps(handler_maps) + + assert handler_maps[0][ValueError].__name__ == "_sentry_lilya_exception_handler" + + +def test_middleware_exception_handler_is_captured(sentry_init, capture_events): + sentry_init(integrations=[LilyaIntegration()]) + events = capture_events() + + app = lilya_app_factory(middleware=[DefineMiddleware(RaisingMiddleware)]) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/message") + + assert response.status_code == 500 + (event,) = events + assert event["exception"]["values"][0]["mechanism"] == { + "type": "lilya", + "handled": False, + } + + +@pytest.mark.asyncio +async def test_wrapped_status_handler_captures_handled_http_exception( + sentry_init, capture_events +): + async def server_error_handler(request, exc): + return PlainText("handled", status_code=500) + + sentry_init(integrations=[LilyaIntegration()]) + events = capture_events() + + handler_maps = ({500: server_error_handler},) + _wrap_exception_handler_maps(handler_maps) + response = await handler_maps[0][500]( + object(), HTTPException(status_code=500, detail="handled") + ) + + assert response.status_code == 500 + (event,) = events + assert event["exception"]["values"][0]["mechanism"] == { + "type": "lilya", + "handled": True, + } + + +@pytest.mark.asyncio +async def test_direct_handle_exception_reference_is_patched( + sentry_init, capture_events +): + async def server_error_handler(request, exc): + return PlainText("handled", status_code=500) + + sentry_init(integrations=[LilyaIntegration()]) + events = capture_events() + sent_messages = [] + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + sent_messages.append(message) + + scope = {"type": "http", "method": "GET", "path": "/handled", "headers": []} + request = Request(scope, receive, send) + + await _direct_handle_exception( + scope, + request, + HTTPException(status_code=500, detail="handled"), + server_error_handler, + ) + + assert _direct_handle_exception.__name__ == "_sentry_patched_handle_exception" + assert any( + message["type"] == "http.response.start" and message["status"] == 500 + for message in sent_messages + ) + (event,) = events + assert event["exception"]["values"][0]["mechanism"] == { + "type": "lilya", + "handled": True, + } + + +def test_json_body_is_attached_without_mutating_request(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + events = capture_events() + + client = TestClient(lilya_app_factory()) + response = client.post( + "/body/json", + json={"username": "Jane", "password": "secret"}, + cookies={"session": "abc"}, + ) + + assert response.status_code == 200 + assert response.json() == {"username": "Jane", "password": "secret"} + + event = _message_from_events(events, "json body") + assert "session" in event["request"]["cookies"] + assert event["request"]["data"] == { + "username": "Jane", + "password": "[Filtered]", + } + + +@pytest.mark.parametrize("json_body", [{}, [], "", False, None]) +def test_falsy_json_body_is_attached(sentry_init, capture_events, json_body): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + events = capture_events() + + client = TestClient(lilya_app_factory()) + if json_body is None: + response = client.post( + "/body/json/echo", + content=b"null", + headers={"content-type": "application/json"}, + ) + else: + response = client.post("/body/json/echo", json=json_body) + + assert response.status_code == 200 + event = _message_from_events(events, "json echo") + assert event["request"]["data"] == json_body + + +def test_form_body_is_preserved(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + events = capture_events() + + client = TestClient(lilya_app_factory()) + response = client.post( + "/body/form", + data={"username": "Jane", "password": "secret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"username": "Jane"} + + event = _message_from_events(events, "form body") + assert "data" not in event["request"] + + +def test_streaming_request_is_preserved(sentry_init): + sentry_init(integrations=[LilyaIntegration()]) + + client = TestClient(lilya_app_factory()) + response = client.post("/body/stream", content=b"hello world") + + assert response.status_code == 200 + assert response.text == "hello world" + + +def test_streaming_request_with_span_streaming_is_preserved( + sentry_init, + capture_items, +): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + client = TestClient(lilya_app_factory()) + response = client.post("/body/stream", content=b"hello world") + sentry_sdk.flush() + + assert response.status_code == 200 + assert response.text == "hello world" + assert items + + +def test_streaming_body_data_is_attached_to_server_span( + sentry_init, + capture_items, +): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[LilyaIntegration(middleware_spans=True)], + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + client = TestClient( + lilya_app_factory(middleware=[DefineMiddleware(SampleMiddleware)]) + ) + response = client.post( + "/body/json", + json={"username": "Jane", "password": "secret"}, + ) + sentry_sdk.flush() + + assert response.status_code == 200 + spans = [item.payload for item in items] + server_span = next( + span for span in spans if span["attributes"].get("sentry.op") == "http.server" + ) + assert json.loads(server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA]) == { + "username": "Jane", + "password": "secret", + } + + middleware_spans = [ + span + for span in spans + if span["attributes"].get("sentry.op") == "middleware.lilya" + ] + assert middleware_spans + assert all( + SPANDATA.HTTP_REQUEST_BODY_DATA not in span["attributes"] + for span in middleware_spans + ) + + +def test_streaming_response_is_preserved(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0, integrations=[LilyaIntegration()]) + events = capture_events() + + client = TestClient(lilya_app_factory()) + response = client.get("/streaming-response") + + assert response.status_code == 200 + assert response.text == "hello world" + assert _transaction_from_events(events)["transaction"] == "/streaming-response" + + +def test_background_task_is_preserved(sentry_init): + sentry_init(integrations=[LilyaIntegration()]) + + app = lilya_app_factory() + client = TestClient(app) + response = client.get("/background") + + assert response.status_code == 200 + assert app.background_calls == ["done"] + + +def test_user_is_attached_from_lilya_authentication(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + events = capture_events() + + app = lilya_app_factory( + middleware=[DefineMiddleware(AuthenticationMiddleware, backend=BasicAuth())] + ) + client = TestClient(app) + response = client.get("/auth-message", auth=("lilya", "secret")) + + assert response.status_code == 200 + event = _message_from_events(events, "auth message") + assert event["user"] == { + "id": "user-1", + "username": "lilya", + "email": "lilya@example.com", + } + + +def test_user_is_limited_to_sentry_fields(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + events = capture_events() + + app = lilya_app_factory( + middleware=[DefineMiddleware(AuthenticationMiddleware, backend=RichUserAuth())] + ) + client = TestClient(app) + response = client.get("/auth-message") + + assert response.status_code == 200 + event = _message_from_events(events, "auth message") + assert event["user"] == { + "id": "user-1", + "username": "lilya", + "email": "lilya@example.com", + } + + +def test_authentication_error_response_is_preserved(sentry_init): + sentry_init(integrations=[LilyaIntegration()]) + + app = lilya_app_factory( + middleware=[DefineMiddleware(AuthenticationMiddleware, backend=FailingAuth())] + ) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/auth-message") + + assert response.status_code == 400 + assert response.text == "authentication failed" + + +@pytest.mark.asyncio +async def test_authentication_middleware_delegates_to_lilya_call(sentry_init): + sentry_init(integrations=[LilyaIntegration()]) + + assert ( + AuthenticationMiddleware.__call__.__name__ + != "_sentry_patched_authentication_call" + ) + assert ( + AuthenticationMiddleware.authenticate.__name__ == "_sentry_patched_authenticate" + ) + + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + return None + + class CustomAuthenticationMiddleware(AuthenticationMiddleware): + called = False + + async def __call__(self, scope, receive, send): + self.called = True + await super().__call__(scope, receive, send) + + middleware = CustomAuthenticationMiddleware(app, backend=BasicAuth()) + scope = { + "type": "http", + "method": "GET", + "path": "/auth", + "headers": [], + } + + await middleware(scope, receive, send) + + assert middleware.called is True + + +@pytest.mark.asyncio +async def test_authentication_middleware_keeps_concurrent_apps_isolated(sentry_init): + sentry_init(send_default_pii=True, integrations=[LilyaIntegration()]) + + both_requests_started = asyncio.Event() + first_request_can_finish = asyncio.Event() + second_request_can_finish = asyncio.Event() + started_paths = [] + + async def app(scope, receive, send): + started_paths.append(scope["path"]) + if len(started_paths) == 2: + both_requests_started.set() + + if scope["path"] == "/first": + await first_request_can_finish.wait() + else: + await second_request_can_finish.wait() + + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + return None + + def scope(path, username): + token = base64.b64encode(("%s:secret" % username).encode("ascii")) + return { + "type": "http", + "method": "GET", + "path": path, + "headers": [(b"authorization", b"Basic " + token)], + } + + middleware = AuthenticationMiddleware(app, backend=BasicAuth()) + first_request = asyncio.create_task( + middleware(scope("/first", "first"), receive, send) + ) + second_request = asyncio.create_task( + middleware(scope("/second", "second"), receive, send) + ) + + await asyncio.wait_for(both_requests_started.wait(), timeout=1.0) + first_request_can_finish.set() + await asyncio.wait_for(first_request, timeout=1.0) + assert middleware.app is app + + second_request_can_finish.set() + await asyncio.wait_for(second_request, timeout=1.0) + assert middleware.app is app + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_middleware_spans( + sentry_init, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(middleware_spans=True)], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + + app = lilya_app_factory(middleware=[DefineMiddleware(SampleMiddleware)]) + client = TestClient(app, raise_server_exceptions=False) + + if span_streaming: + items = capture_items("span") + response = client.get("/message") + sentry_sdk.flush() + + assert response.status_code == 200 + lilya_spans = [ + item.payload + for item in items + if item.payload["attributes"].get("sentry.op") == "middleware.lilya" + ] + assert any( + span["name"] == "SampleMiddleware" + and span["attributes"] + == ApproxDict({"middleware.name": "SampleMiddleware"}) + for span in lilya_spans + ) + else: + events = capture_events() + response = client.get("/message") + + assert response.status_code == 200 + transaction = _transaction_from_events(events) + assert transaction["transaction"] == "/message" + assert transaction["transaction_info"] == {"source": "route"} + lilya_spans = [ + span for span in transaction["spans"] if span["op"] == "middleware.lilya" + ] + assert any( + span["description"] == "SampleMiddleware" + and span["tags"] == {"lilya.middleware_name": "SampleMiddleware"} + for span in lilya_spans + ) + + +def test_middleware_spans_disabled(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(middleware_spans=False)], + ) + events = capture_events() + + app = lilya_app_factory(middleware=[DefineMiddleware(SampleMiddleware)]) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/message") + + assert response.status_code == 200 + transaction = _transaction_from_events(events) + assert not [ + span for span in transaction["spans"] if span["op"] == "middleware.lilya" + ] + + +@pytest.mark.parametrize( + "middleware", + [ + ReceiveSendMiddleware, + PartialReceiveSendMiddleware, + ], +) +def test_middleware_receive_send_is_preserved(sentry_init, middleware): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(middleware_spans=True)], + ) + + app = lilya_app_factory(middleware=[DefineMiddleware(middleware)]) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/message") + + assert response.status_code == 200 + + +def test_websocket_is_preserved(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0, integrations=[LilyaIntegration()]) + events = capture_events() + + client = TestClient(lilya_app_factory()) + with client.websocket_connect("/ws/main") as websocket: + websocket.send_text("hello") + assert websocket.receive_text() == "echo:hello" + + transaction = _transaction_from_events(events) + assert transaction["transaction"] == "/ws/{room}" + assert transaction["contexts"]["trace"]["op"] == "websocket.server" + + +def test_websocket_transaction_name_with_middleware_spans(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(middleware_spans=True)], + ) + events = capture_events() + + app = lilya_app_factory(middleware=[DefineMiddleware(SampleMiddleware)]) + client = TestClient(app) + with client.websocket_connect("/ws/main") as websocket: + websocket.send_text("hello") + assert websocket.receive_text() == "echo:hello" + + transaction = _transaction_from_events(events) + assert transaction["transaction"] == "/ws/{room}" + assert transaction["contexts"]["trace"]["op"] == "websocket.server" + + +def test_middleware_exception_uses_asgi_transaction_name(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[LilyaIntegration(middleware_spans=True)], + ) + events = capture_events() + + app = lilya_app_factory(middleware=[DefineMiddleware(RaisingMiddleware)]) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/message") + + assert response.status_code == 500 + transaction = _transaction_from_events(events) + assert transaction["transaction"].endswith("/message") + assert transaction["transaction"] != "generic Lilya request" + assert transaction["transaction_info"] == {"source": "url"} + + +def test_lifespan_is_preserved(sentry_init): + state = [] + + class Lifespan: + async def __aenter__(self): + state.append("startup") + + async def __aexit__(self, exc_type, exc, tb): + state.append("shutdown") + + def lifespan(app): + return Lifespan() + + sentry_init(integrations=[LilyaIntegration()]) + + app = lilya_app_factory(lifespan=lifespan) + with TestClient(app) as client: + assert state == ["startup"] + response = client.get("/message") + assert response.status_code == 200 + + assert state == ["startup", "shutdown"] diff --git a/tox.ini b/tox.ini index 889af6d6bf..c9e1d1c04d 100644 --- a/tox.ini +++ b/tox.ini @@ -336,6 +336,9 @@ envlist = {py3.9,py3.11,py3.12}-falcon-v4.3.1 {py3.9,py3.11,py3.12}-falcon-latest + {py3.10,py3.13,py3.14,py3.14t}-lilya-v0.27.0 + {py3.10,py3.13,py3.14,py3.14t}-lilya-latest + {py3.8,py3.10,py3.11}-litestar-v2.0.1 {py3.8,py3.11,py3.12}-litestar-v2.8.3 {py3.8,py3.12,py3.13}-litestar-v2.16.0 @@ -16384,6 +16387,142 @@ deps = falcon-latest: falcon==4.3.1 + lilya-v0.27.0: lilya==0.27.0 + + py3.10-lilya-v0.27.0: anyio==4.14.1 + py3.10-lilya-v0.27.0: multidict==6.7.1 + py3.10-lilya-v0.27.0: httpx==0.28.1 + py3.10-lilya-v0.27.0: httpcore==1.0.9 + py3.10-lilya-v0.27.0: pytest-asyncio==1.4.0 + py3.10-lilya-v0.27.0: backports.asyncio.runner==1.2.0 + py3.10-lilya-v0.27.0: pytest==9.1.1 + py3.10-lilya-v0.27.0: pluggy==1.6.0 + py3.10-lilya-v0.27.0: exceptiongroup==1.3.1 + py3.10-lilya-v0.27.0: h11==0.16.0 + py3.10-lilya-v0.27.0: idna==3.18 + py3.10-lilya-v0.27.0: iniconfig==2.3.0 + py3.10-lilya-v0.27.0: monkay==0.5.3 + py3.10-lilya-v0.27.0: packaging==26.2 + py3.10-lilya-v0.27.0: Pygments==2.20.0 + py3.10-lilya-v0.27.0: tomli==2.4.1 + py3.10-lilya-v0.27.0: typing_extensions==4.16.0 + py3.10-lilya-v0.27.0: certifi==2026.6.17 + + py3.13-lilya-v0.27.0: anyio==4.14.1 + py3.13-lilya-v0.27.0: multidict==6.7.1 + py3.13-lilya-v0.27.0: httpx==0.28.1 + py3.13-lilya-v0.27.0: httpcore==1.0.9 + py3.13-lilya-v0.27.0: pytest-asyncio==1.4.0 + py3.13-lilya-v0.27.0: pytest==9.1.1 + py3.13-lilya-v0.27.0: pluggy==1.6.0 + py3.13-lilya-v0.27.0: h11==0.16.0 + py3.13-lilya-v0.27.0: idna==3.18 + py3.13-lilya-v0.27.0: iniconfig==2.3.0 + py3.13-lilya-v0.27.0: monkay==0.5.3 + py3.13-lilya-v0.27.0: packaging==26.2 + py3.13-lilya-v0.27.0: Pygments==2.20.0 + py3.13-lilya-v0.27.0: certifi==2026.6.17 + + py3.14-lilya-v0.27.0: anyio==4.14.1 + py3.14-lilya-v0.27.0: multidict==6.7.1 + py3.14-lilya-v0.27.0: httpx==0.28.1 + py3.14-lilya-v0.27.0: httpcore==1.0.9 + py3.14-lilya-v0.27.0: pytest-asyncio==1.4.0 + py3.14-lilya-v0.27.0: pytest==9.1.1 + py3.14-lilya-v0.27.0: pluggy==1.6.0 + py3.14-lilya-v0.27.0: h11==0.16.0 + py3.14-lilya-v0.27.0: idna==3.18 + py3.14-lilya-v0.27.0: iniconfig==2.3.0 + py3.14-lilya-v0.27.0: monkay==0.5.3 + py3.14-lilya-v0.27.0: packaging==26.2 + py3.14-lilya-v0.27.0: Pygments==2.20.0 + py3.14-lilya-v0.27.0: certifi==2026.6.17 + + py3.14t-lilya-v0.27.0: anyio==4.14.1 + py3.14t-lilya-v0.27.0: multidict==6.7.1 + py3.14t-lilya-v0.27.0: httpx==0.28.1 + py3.14t-lilya-v0.27.0: httpcore==1.0.9 + py3.14t-lilya-v0.27.0: pytest-asyncio==1.4.0 + py3.14t-lilya-v0.27.0: pytest==9.1.1 + py3.14t-lilya-v0.27.0: pluggy==1.6.0 + py3.14t-lilya-v0.27.0: h11==0.16.0 + py3.14t-lilya-v0.27.0: idna==3.18 + py3.14t-lilya-v0.27.0: iniconfig==2.3.0 + py3.14t-lilya-v0.27.0: monkay==0.5.3 + py3.14t-lilya-v0.27.0: packaging==26.2 + py3.14t-lilya-v0.27.0: Pygments==2.20.0 + py3.14t-lilya-v0.27.0: certifi==2026.6.17 + + lilya-latest: lilya==0.27.0 + + py3.10-lilya-latest: anyio==4.14.1 + py3.10-lilya-latest: multidict==6.7.1 + py3.10-lilya-latest: httpx==0.28.1 + py3.10-lilya-latest: httpcore==1.0.9 + py3.10-lilya-latest: pytest-asyncio==1.4.0 + py3.10-lilya-latest: backports.asyncio.runner==1.2.0 + py3.10-lilya-latest: pytest==9.1.1 + py3.10-lilya-latest: pluggy==1.6.0 + py3.10-lilya-latest: exceptiongroup==1.3.1 + py3.10-lilya-latest: h11==0.16.0 + py3.10-lilya-latest: idna==3.18 + py3.10-lilya-latest: iniconfig==2.3.0 + py3.10-lilya-latest: monkay==0.5.3 + py3.10-lilya-latest: packaging==26.2 + py3.10-lilya-latest: Pygments==2.20.0 + py3.10-lilya-latest: tomli==2.4.1 + py3.10-lilya-latest: typing_extensions==4.16.0 + py3.10-lilya-latest: certifi==2026.6.17 + + py3.13-lilya-latest: anyio==4.14.1 + py3.13-lilya-latest: multidict==6.7.1 + py3.13-lilya-latest: httpx==0.28.1 + py3.13-lilya-latest: httpcore==1.0.9 + py3.13-lilya-latest: pytest-asyncio==1.4.0 + py3.13-lilya-latest: pytest==9.1.1 + py3.13-lilya-latest: pluggy==1.6.0 + py3.13-lilya-latest: h11==0.16.0 + py3.13-lilya-latest: idna==3.18 + py3.13-lilya-latest: iniconfig==2.3.0 + py3.13-lilya-latest: monkay==0.5.3 + py3.13-lilya-latest: packaging==26.2 + py3.13-lilya-latest: Pygments==2.20.0 + py3.13-lilya-latest: certifi==2026.6.17 + + py3.14-lilya-latest: anyio==4.14.1 + py3.14-lilya-latest: multidict==6.7.1 + py3.14-lilya-latest: httpx==0.28.1 + py3.14-lilya-latest: httpcore==1.0.9 + py3.14-lilya-latest: pytest-asyncio==1.4.0 + py3.14-lilya-latest: pytest==9.1.1 + py3.14-lilya-latest: pluggy==1.6.0 + py3.14-lilya-latest: h11==0.16.0 + py3.14-lilya-latest: idna==3.18 + py3.14-lilya-latest: iniconfig==2.3.0 + py3.14-lilya-latest: monkay==0.5.3 + py3.14-lilya-latest: packaging==26.2 + py3.14-lilya-latest: Pygments==2.20.0 + py3.14-lilya-latest: certifi==2026.6.17 + + py3.14t-lilya-latest: anyio==4.14.1 + py3.14t-lilya-latest: multidict==6.7.1 + py3.14t-lilya-latest: httpx==0.28.1 + py3.14t-lilya-latest: httpcore==1.0.9 + py3.14t-lilya-latest: pytest-asyncio==1.4.0 + py3.14t-lilya-latest: pytest==9.1.1 + py3.14t-lilya-latest: pluggy==1.6.0 + py3.14t-lilya-latest: h11==0.16.0 + py3.14t-lilya-latest: idna==3.18 + py3.14t-lilya-latest: iniconfig==2.3.0 + py3.14t-lilya-latest: monkay==0.5.3 + py3.14t-lilya-latest: packaging==26.2 + py3.14t-lilya-latest: Pygments==2.20.0 + py3.14t-lilya-latest: certifi==2026.6.17 + + lilya: httpx + lilya: pytest-asyncio + lilya: anyio>=3,<5 + litestar-v2.0.1: litestar==2.0.1 py3.8-litestar-v2.0.1: pytest-asyncio==0.24.0 @@ -18662,6 +18801,7 @@ setenv = langchain-notiktoken: _TESTPATH=tests/integrations/langchain langgraph: _TESTPATH=tests/integrations/langgraph launchdarkly: _TESTPATH=tests/integrations/launchdarkly + lilya: _TESTPATH=tests/integrations/lilya litellm: _TESTPATH=tests/integrations/litellm litestar: _TESTPATH=tests/integrations/litestar loguru: _TESTPATH=tests/integrations/loguru diff --git a/uv.lock b/uv.lock index fb1818bba8..08dab45748 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,7 @@ typing = [ { name = "httpx" }, { name = "httpx2" }, { name = "launchdarkly-server-sdk" }, + { name = "lilya", specifier = ">=0.27.0" }, { name = "loguru" }, { name = "mcp", specifier = ">=2.0.0b1" }, { name = "mypy" }, @@ -942,6 +943,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "lilya" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "monkay" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/ff/99ccb2258f611ea84ad4f1f5c8ee2806eeee7e61733c27b6f12f68cb7824/lilya-0.27.0.tar.gz", hash = "sha256:176a94af9f75ec1ad1b0cdd74ebb00d3f251070cfb1986062783cd611ec8ca87", size = 307944, upload-time = "2026-07-05T10:50:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/62/f72492df640220b1ae8aa4c4d3fa003b3083820f40c9e3510133123bcc00/lilya-0.27.0-py3-none-any.whl", hash = "sha256:c8c807ae3c989473e1bcb51fc8597a15e4e945e0fc4dd165230688f7792a6032", size = 433963, upload-time = "2026-07-05T10:50:03.285Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1091,6 +1106,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, ] +[[package]] +name = "monkay" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/8d/2a2bd4278801e99b601d24f67706c083ea2c79938cab0d67ee7a9d13048a/monkay-0.5.3.tar.gz", hash = "sha256:4601797cfae1c80deafeeca7de24796c8bf53b7bb0d182af2c6acbefaf27bcac", size = 71342, upload-time = "2026-04-16T05:12:20.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/4b/2130545528bd2ba250e9e63456c315e1812498830bd7b2e52c08c619380c/monkay-0.5.3-py3-none-any.whl", hash = "sha256:effe438b07fad650ef2d534f07f3d00fb5b8a61917767cfa26156b80e1b51240", size = 33227, upload-time = "2026-04-16T05:12:21.525Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "mypy" version = "2.1.0"