diff --git a/pyproject.toml b/pyproject.toml index a565198..15a4565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,13 @@ path = "structlog_gcp/__about__.py" [tool.uv] dev-dependencies = [ "build[uv]>=1.2.2", + "httpx>=0.27.2", "mypy>=1.11.2", - "pytest>=8.3.3", + "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", + "pytest>=8.3.3", "ruff>=0.6.6", + "starlette>=0.38.5", ] [tool.pytest.ini_options] @@ -54,7 +57,9 @@ addopts = [ "--verbose", "--verbose", ] -testpaths = "tests" +testpaths = ["structlog_gcp", "tests"] +asyncio_default_fixture_loop_scope = "session" +asyncio_mode = "auto" [tool.coverage.run] branch = true diff --git a/structlog_gcp/base.py b/structlog_gcp/base.py index c4465f4..c203b15 100644 --- a/structlog_gcp/base.py +++ b/structlog_gcp/base.py @@ -2,7 +2,7 @@ import structlog.processors from structlog.typing import Processor -from . import error_reporting, processors +from . import error_reporting, http, processors def build_processors( @@ -57,6 +57,8 @@ def build_gcp_processors( procs.extend(processors.setup_log_severity()) procs.extend(processors.setup_code_location()) + procs.append(http.request_processor) + # Errors: log exceptions procs.extend(error_reporting.setup_exceptions()) diff --git a/structlog_gcp/http/__init__.py b/structlog_gcp/http/__init__.py new file mode 100644 index 0000000..2ba79e4 --- /dev/null +++ b/structlog_gcp/http/__init__.py @@ -0,0 +1,5 @@ +from .base import request_processor + +__all__ = [ + "request_processor", +] diff --git a/structlog_gcp/http/adapters/__init__.py b/structlog_gcp/http/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/structlog_gcp/http/adapters/starlette.py b/structlog_gcp/http/adapters/starlette.py new file mode 100644 index 0000000..ead78d8 --- /dev/null +++ b/structlog_gcp/http/adapters/starlette.py @@ -0,0 +1,49 @@ +import time +from types import TracebackType +from typing import Type + +from starlette.requests import Request +from starlette.responses import Response + +from ..base import HTTPRequest + + +class RequestAdapter: + def __init__(self, request: Request): + self.request = request + self.start_time = time.perf_counter_ns() + + async def __aenter__(self) -> "RequestAdapter": + return self + + async def __aexit__( + self, + exc_type: Type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + return None + + def adapt(self, response: Response) -> HTTPRequest: + request = self.request # alias + + latency_ns = time.perf_counter_ns() - self.start_time + http_version = request.scope["http_version"] + + if request.client is not None: + client_host = request.client.host + client_port = request.client.port + remote_ip = f"{client_host}:{client_port}" + else: + remote_ip = None + + return HTTPRequest( + method=request.method, + url=str(request.url), + status=response.status_code, + latency_ns=latency_ns, + protocol=f"HTTP/{http_version}", + user_agent=request.headers.get("user-agent"), + referer=request.headers.get("referer"), + remote_ip=remote_ip, + ) diff --git a/structlog_gcp/http/base.py b/structlog_gcp/http/base.py new file mode 100644 index 0000000..2d4ba8d --- /dev/null +++ b/structlog_gcp/http/base.py @@ -0,0 +1,151 @@ +from dataclasses import dataclass +from typing import Any + +from structlog.typing import EventDict, WrappedLogger + +from ..types import CLOUD_LOGGING_KEY + +__all__ = ["HTTPRequest"] + + +@dataclass +class HTTPRequest: + method: str | None = None + """The request method. + + Examples: ``GET``, ``HEAD``, ``PUT``, ``POST``.""" + + url: str | None = None + """The scheme (``http``, ``https``), the host name, the path and the query portion of the URL that was requested. + + Example: ``http://example.com/some/info?color=red`` + """ + + size: int | None = None + "The size of the HTTP request message in bytes, including the request headers and the request body." + + status: int | None = None + """The response code indicating the status of response. + + Examples: ``200``, ``404``.""" + + response_size: int | None = None + """The size of the HTTP response message sent back to the client, in bytes, including the response headers and the response body.""" + + latency_ns: int | None = None + """The request processing latency (in nanoseconds) on the server, from the time the request was received until the response was sent. + + For WebSocket connections, this field refers to the entire time duration of the connection.""" + + protocol: str | None = None + """Protocol used for the request. + + Examples: ``HTTP/1.1``, ``HTTP/2``.""" + + referer: str | None = None + "The referer URL of the request, as defined in `HTTP/1.1 Header Field Definitions `_." + + user_agent: str | None = None + """The user agent sent by the client. + + Example: ``Mozilla/4.0 (compatible; MSIE 6.0; Windows 98; Q312461; .NET CLR 1.0.3705)``.""" + + remote_ip: str | None = None + """The IP address (IPv4 or IPv6) of the client that issued the HTTP request. This field can include port information. + + Examples: ``192.168.1.1``, ``10.0.0.1:80``, ``FE80::0202:B3FF:FE1E:8329``.""" + + server_ip: str | None = None + """The IP address (IPv4 or IPv6) of the origin server that the request was sent to. This field can include port information. + + Examples: ``192.168.1.1``, ``10.0.0.1:80``, ``FE80::0202:B3FF:FE1E:8329``.""" + + cache_lookup: bool | None = None + "Whether or not a cache lookup was attempted." + + cache_hit: bool | None = None + "Whether or not an entity was served from cache (with or without validation)." + + cache_validated_with_origin_server: bool | None = None + """Whether or not the response was validated with the origin server before being served from cache. + + This field is only meaningful if ``cache_hit`` is ``True``.""" + + cache_fill: int | None = None + "The number of HTTP response bytes inserted into cache. Set only when a cache fill was attempted." + + def format(self) -> dict[str, Any]: + """Format as https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#httprequest""" + + values = [ + ("requestMethod", self.method), + ("requestUrl", self.url), + ("requestSize", str_or_none(self.size)), + ("status", self.status), + ("responseSize", str_or_none(self.response_size)), + ("userAgent", self.user_agent), + ("remoteIp", self.remote_ip), + ("serverIp", self.server_ip), + ("referer", self.referer), + ("latency", seconds_or_none(self.latency_ns)), + ("cacheLookup", self.cache_lookup), + ("cacheHit", self.cache_hit), + ("cacheValidatedWithOriginServer", self.cache_validated_with_origin_server), + ("cacheFillBytes", str_or_none(self.cache_fill)), + ("protocol", self.protocol), + ] + + return {k: v for (k, v) in values if v is not None} + + +def request_processor( + logger: WrappedLogger, method_name: str, event_dict: EventDict +) -> EventDict: + if "http_request" not in event_dict: + return event_dict + + request: HTTPRequest = event_dict.pop("http_request") + + event_dict[CLOUD_LOGGING_KEY]["httpRequest"] = request.format() + return event_dict + + +def str_or_none(value: int | None) -> str | None: + """Convert a value to string, or ``None``. + + >>> str_or_none(None) + >>> str_or_none(15) + '15' + """ + + if value is None: + return None + + return str(value) + + +def seconds_or_none(value_ns: int | None) -> str | None: + """Convert a value in nanosecond to a duration in second, or ``None``. + + >>> ns = 1 + >>> us = ns * 1000 + >>> ms = us * 1000 + >>> s = ms * 1000 + >>> seconds_or_none(None) + >>> seconds_or_none(1 * ns) + '0.000000001s' + >>> seconds_or_none(15 * ms) + '0.015s' + >>> seconds_or_none(1 * s) + '1s' + >>> seconds_or_none(99.5 * s) + '99.5s' + """ + + if value_ns is None: + return None + + seconds = value_ns / 1e9 + # Remove trailing 0 and . to keep number cleaner + seconds_str = f"{seconds:.9f}".rstrip("0.") + return f"{seconds_str}s" diff --git a/tests/conftest.py b/tests/conftest.py index 043e30f..68dda20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,8 +40,11 @@ def logger(mock_logger_env: None) -> Generator[WrappedLogger, None, None]: structlog.reset_defaults() +T_stdout = Callable[[], str] + + @pytest.fixture -def stdout(capsys: CaptureFixture[str]) -> Callable[[], str]: +def stdout(capsys: CaptureFixture[str]) -> T_stdout: def read() -> str: output = capsys.readouterr() assert "" == output.err diff --git a/tests/test_adapter_starlette.py b/tests/test_adapter_starlette.py new file mode 100644 index 0000000..3470b76 --- /dev/null +++ b/tests/test_adapter_starlette.py @@ -0,0 +1,102 @@ +import json +from unittest.mock import patch + +import structlog +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient +from starlette.types import ASGIApp +from structlog.typing import WrappedLogger + +from structlog_gcp.http.adapters.starlette import RequestAdapter + +from .conftest import T_stdout + + +class LoggerMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + self.logger = structlog.get_logger() + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + mocked_scope = {} + if "no-client" in str(request.url): + # Remove the client information to test the adapter works correctly + # when the client is not set. + mocked_scope = {"client": None} + + async with RequestAdapter(request) as adapter: + with patch.dict(request.scope, mocked_scope): + response = await call_next(request) + r = adapter.adapt(response) + + # We don't know how much time the call will take, and we can't assert + # the latency value later on, so we fake it here to a static value. + assert r.latency_ns is not None + assert r.latency_ns > 0 + r.latency_ns = 42 * 1_000_000 + + self.logger.info( + f"{r.method} {r.url} {r.protocol} -> {r.status}", + http_request=r, + ) + + return response + + +def index(request: Request) -> PlainTextResponse: + return PlainTextResponse("ok") + + +def create_app() -> Starlette: + middlewares = [Middleware(LoggerMiddleware)] + routes = [Route("/", index)] + return Starlette(routes=routes, middleware=middlewares) + + +async def test_basic(stdout: T_stdout, logger: WrappedLogger) -> None: + app = create_app() + client = TestClient(app) + + response = client.get("/?foo=bar") + assert response.status_code == 200 + + expected = { + "logging.googleapis.com/sourceLocation": { + "file": "/app/test.py", + "function": "test:test123", + "line": "42", + }, + "httpRequest": { + "latency": "0.042s", + "protocol": "HTTP/1.1", + "remoteIp": "testclient:50000", + "requestMethod": "GET", + "requestUrl": "http://testserver/?foo=bar", + "status": 200, + "userAgent": "testclient", + }, + "message": "GET http://testserver/?foo=bar HTTP/1.1 -> 200", + "severity": "INFO", + "time": "2023-04-01T08:00:00.000000Z", + } + + msg = json.loads(stdout()) + assert msg == expected + + +async def test_no_remote_ip(stdout: T_stdout, logger: WrappedLogger) -> None: + app = create_app() + client = TestClient(app) + + response = client.get("/?no-client") + assert response.status_code == 200 + + msg = json.loads(stdout()) + assert "remoteIp" not in msg["httpRequest"] diff --git a/tests/test_http_processor.py b/tests/test_http_processor.py new file mode 100644 index 0000000..9f060f5 --- /dev/null +++ b/tests/test_http_processor.py @@ -0,0 +1,56 @@ +import json + +from structlog.typing import WrappedLogger + +from structlog_gcp.http.base import HTTPRequest + +from .conftest import T_stdout + + +def test_http_1() -> None: + request = HTTPRequest(method="GET", status=200, size=512) + result = request.format() + + expected = { + "requestMethod": "GET", + "status": 200, + "requestSize": "512", + } + + assert result == expected + + +def test_http_latency() -> None: + request = HTTPRequest(method="GET", latency_ns=15_000_000) + result = request.format() + + expected = { + "requestMethod": "GET", + "latency": "0.015s", + } + + assert result == expected + + +def test_http_logger(stdout: T_stdout, logger: WrappedLogger) -> None: + request = HTTPRequest(method="GET", status=200) + + logger.info("test", http_request=request) + + msg = json.loads(stdout()) + + expected = { + "httpRequest": { + "requestMethod": "GET", + "status": 200, + }, + "logging.googleapis.com/sourceLocation": { + "file": "/app/test.py", + "function": "test:test123", + "line": "42", + }, + "message": "test", + "severity": "INFO", + "time": "2023-04-01T08:00:00.000000Z", + } + assert msg == expected diff --git a/tests/test_log.py b/tests/test_log.py index 456e946..dcd0595 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,6 +1,5 @@ import datetime import json -from typing import Callable from unittest.mock import patch import structlog @@ -8,7 +7,7 @@ import structlog_gcp -T_stdout = Callable[[], str] +from .conftest import T_stdout def test_normal(stdout: T_stdout, logger: WrappedLogger) -> None: diff --git a/uv.lock b/uv.lock index 1696a54..3651536 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,21 @@ version = 1 requires-python = ">=3.10" +[[package]] +name = "anyio" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, +] + [[package]] name = "build" version = "1.2.2" @@ -22,6 +37,15 @@ uv = [ { name = "uv" }, ] +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -104,6 +128,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/b0/5e8b8674f8d203335a62fdfcfa0d11ebe09e23613c3391033cbba35f7926/httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", size = 83234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "importlib-metadata" version = "8.5.0" @@ -207,6 +278,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -222,27 +305,48 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.6" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/3045a526c57cef4b5ec4d5d154692e31429749a49810a53e785de334c4f6/ruff-0.6.7.tar.gz", hash = "sha256:44e52129d82266fa59b587e2cd74def5637b730a69c4542525dfdecfaae38bd5", size = 3073785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c4/1c5c636f83f905c537785016e9cdd7a36df53c025a2d07940580ecb37bcf/ruff-0.6.7-py3-none-linux_armv6l.whl", hash = "sha256:08277b217534bfdcc2e1377f7f933e1c7957453e8a79764d004e44c40db923f2", size = 10336748 }, + { url = "https://files.pythonhosted.org/packages/84/d9/aa15a56be7ad796f4d7625362aff588f9fc013bbb7323a63571628a2cf2d/ruff-0.6.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c6707a32e03b791f4448dc0dce24b636cbcdee4dd5607adc24e5ee73fd86c00a", size = 9958833 }, + { url = "https://files.pythonhosted.org/packages/27/25/5dd1c32bfc3ad3136c8ebe84312d1bdd2e6c908ac7f60692ec009b7050a8/ruff-0.6.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:533d66b7774ef224e7cf91506a7dafcc9e8ec7c059263ec46629e54e7b1f90ab", size = 9633369 }, + { url = "https://files.pythonhosted.org/packages/0e/3e/01b25484f3cb08fe6fddedf1f55f3f3c0af861a5b5f5082fbe60ab4b2596/ruff-0.6.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17a86aac6f915932d259f7bec79173e356165518859f94649d8c50b81ff087e9", size = 10637415 }, + { url = "https://files.pythonhosted.org/packages/8a/c9/5bb9b849e4777e0f961de43edf95d2af0ab34999a5feee957be096887876/ruff-0.6.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3f8822defd260ae2460ea3832b24d37d203c3577f48b055590a426a722d50ef", size = 10097389 }, + { url = "https://files.pythonhosted.org/packages/52/cf/e08f1c290c7d848ddfb2ae811f24f445c18e1d3e50e01c38ffa7f5a50494/ruff-0.6.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba4efe5c6dbbb58be58dd83feedb83b5e95c00091bf09987b4baf510fee5c99", size = 10951440 }, + { url = "https://files.pythonhosted.org/packages/a2/2d/ca8aa0da5841913c302d8034c6de0ce56c401c685184d8dd23cfdd0003f9/ruff-0.6.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:525201b77f94d2b54868f0cbe5edc018e64c22563da6c5c2e5c107a4e85c1c0d", size = 11708900 }, + { url = "https://files.pythonhosted.org/packages/89/fc/9a83c57baee977c82392e19a328b52cebdaf61601af3d99498e278ef5104/ruff-0.6.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8854450839f339e1049fdbe15d875384242b8e85d5c6947bb2faad33c651020b", size = 11258892 }, + { url = "https://files.pythonhosted.org/packages/d3/a3/254cc7afef702c68ae9079290c2a1477ae0e81478589baf745026d8a4eb5/ruff-0.6.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f0b62056246234d59cbf2ea66e84812dc9ec4540518e37553513392c171cb18", size = 12367932 }, + { url = "https://files.pythonhosted.org/packages/9f/55/53f10c1bd8c3b2ae79aed18e62b22c6346f9296aa0ec80489b8442bd06a9/ruff-0.6.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b1462fa56c832dc0cea5b4041cfc9c97813505d11cce74ebc6d1aae068de36b", size = 10838629 }, + { url = "https://files.pythonhosted.org/packages/84/72/fb335c2b25432c63d15383ecbd7bfc1915e68cdf8d086a08042052144255/ruff-0.6.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:02b083770e4cdb1495ed313f5694c62808e71764ec6ee5db84eedd82fd32d8f5", size = 10648824 }, + { url = "https://files.pythonhosted.org/packages/92/a8/d57e135a8ad99b6a0c6e2a5c590bcacdd57f44340174f4409c3893368610/ruff-0.6.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c05fd37013de36dfa883a3854fae57b3113aaa8abf5dea79202675991d48624", size = 10174368 }, + { url = "https://files.pythonhosted.org/packages/a7/6f/1a30a6e81dcf2fa9ff3f7011eb87fe76c12a3c6bba74db6a1977d763de1f/ruff-0.6.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f49c9caa28d9bbfac4a637ae10327b3db00f47d038f3fbb2195c4d682e925b14", size = 10514383 }, + { url = "https://files.pythonhosted.org/packages/0b/25/df6f2575bc9fe43a6dedfd8dee12896f09a94303e2c828d5f85856bb69a0/ruff-0.6.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0e1655868164e114ba43a908fd2d64a271a23660195017c17691fb6355d59bb", size = 10902340 }, + { url = "https://files.pythonhosted.org/packages/68/62/f2c1031e2fb7b94f9bf0603744e73db4ef90081b0eb1b9639a6feefd52ea/ruff-0.6.7-py3-none-win32.whl", hash = "sha256:a939ca435b49f6966a7dd64b765c9df16f1faed0ca3b6f16acdf7731969deb35", size = 8448033 }, + { url = "https://files.pythonhosted.org/packages/97/80/193d1604a3f7d75eb1b2a7ce6bf0fdbdbc136889a65caacea6ffb29501b1/ruff-0.6.7-py3-none-win_amd64.whl", hash = "sha256:590445eec5653f36248584579c06252ad2e110a5d1f32db5420de35fb0e1c977", size = 9273543 }, + { url = "https://files.pythonhosted.org/packages/8e/a8/4abb5a9f58f51e4b1ea386be5ab2e547035bc1ee57200d1eca2f8909a33e/ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8", size = 8618044 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/47/8220a40d1e60042d3a3a1cdf81cbafe674475cf8d60db2e28d0e4e004069/ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034", size = 3070828 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "0.38.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/aa/57975da16ca0c368bbb5687daa6ad79561c2328a44667a1d6802e94df3e5/starlette-0.38.5.tar.gz", hash = "sha256:04a92830a9b6eb1442c766199d62260c3d4dc9c4f9188360626b1e0273cb7077", size = 2569511 } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/34/a34080926faed8ee41a4faf34e5993e367cd414cec543301113b0930f640/ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb", size = 11353484 }, - { url = "https://files.pythonhosted.org/packages/22/ad/9c0b2fae42bfb54b91161b29b6b73bf73bfe7ddc03d5d3d0b4884a49df95/ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce", size = 11011998 }, - { url = "https://files.pythonhosted.org/packages/93/69/e406b534cbe2f4b992de0f4a8f9a790e4b93a3f5ca56f151e2665db95625/ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182", size = 10524580 }, - { url = "https://files.pythonhosted.org/packages/26/5b/2e6fd424d78bd942dbf51f4a29512d5e698bfd9cad1245ad50ea679d5aa8/ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6", size = 11652644 }, - { url = "https://files.pythonhosted.org/packages/05/73/e6eab18071ac908135bcc9873c0bde240cffd8d5cfcfef8efa00eae186e4/ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6", size = 11096545 }, - { url = "https://files.pythonhosted.org/packages/63/64/e8da4e45174067e584f4d1105a160e018d8148158da7275a52f6090bd4bc/ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8", size = 12005773 }, - { url = "https://files.pythonhosted.org/packages/2b/71/57fb76dc5a93cfa2c7d2a848d0c103e493c6553db3cbf30d642da522ee38/ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f", size = 12758296 }, - { url = "https://files.pythonhosted.org/packages/5a/85/583c969d1b6725aefeef2eb45e88e8ea55c30e017be8e158c322ee8bea56/ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9", size = 12295191 }, - { url = "https://files.pythonhosted.org/packages/a6/eb/7496d2de40818ea32c0ffb75aff405b9de7dda079bcdd45952eb17216e37/ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f", size = 13402527 }, - { url = "https://files.pythonhosted.org/packages/71/27/873696e146821535c84ad2a8dc488fe78298cd0fd1a0d24a946991363b50/ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79", size = 11872520 }, - { url = "https://files.pythonhosted.org/packages/fd/88/6da14ef37b88c42191246a217c58e1d5f0a83db9748e018e94ad05936466/ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb", size = 11683880 }, - { url = "https://files.pythonhosted.org/packages/37/a3/8b9650748f72552e83f11f1d16786a24346128f4460d5b6945ed1f651901/ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68", size = 11186349 }, - { url = "https://files.pythonhosted.org/packages/ba/92/49523f745cf2330de1347110048cfd453de9486bef5498360595b6627074/ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6", size = 11555881 }, - { url = "https://files.pythonhosted.org/packages/99/cc/cd9ca48cb0b9b1b52710dd2f1e30c347f6cee5c455b53368665d274bfcd4/ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe", size = 11956426 }, - { url = "https://files.pythonhosted.org/packages/9d/a2/35c45a784d86daf6dab1510cbb5e572bee33b5c035e6f8e78f510c393acf/ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84", size = 9263539 }, - { url = "https://files.pythonhosted.org/packages/4a/87/c2a6fa6d1ec73a0f8b0713d69a29f8cdc17b251cd0e0ca3a96a78246ddce/ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1", size = 10114810 }, - { url = "https://files.pythonhosted.org/packages/aa/b3/bfe8725d1c38addc86a2b5674ba4e3fd8ab3edb320dcd3f815b227b78b84/ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a", size = 9485847 }, + { url = "https://files.pythonhosted.org/packages/90/1a/8853ba4cea1ec99535ac9be5795a50ca92cddd04d57bbaa56e866cb7548c/starlette-0.38.5-py3-none-any.whl", hash = "sha256:632f420a9d13e3ee2a6f18f437b0a9f1faecb0bc42e1942aa2ea0e379a4c4206", size = 71447 }, ] [[package]] @@ -265,10 +369,13 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "build", extra = ["uv"] }, + { name = "httpx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "starlette" }, ] [package.metadata] @@ -277,10 +384,13 @@ requires-dist = [{ name = "structlog" }] [package.metadata.requires-dev] dev = [ { name = "build", extras = ["uv"], specifier = ">=1.2.2" }, + { name = "httpx", specifier = ">=0.27.2" }, { name = "mypy", specifier = ">=1.11.2" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.6.6" }, + { name = "starlette", specifier = ">=0.38.5" }, ] [[package]]