flowsurgeon 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.3
2
+ Name: flowsurgeon
3
+ Version: 0.2.0
4
+ Summary: FlowSurgeon — framework-agnostic profiling middleware for Python (WSGI & ASGI).
5
+ Keywords: wsgi,asgi,middleware,profiling,debugging,fastapi,flask,starlette
6
+ Author: Samandar-Komilov
7
+ Author-email: Samandar-Komilov <voidpointer07@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
16
+ Classifier: Topic :: Software Development :: Debuggers
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Project-URL: Homepage, https://github.com/Samandar-Komilov/flowsurgeon
20
+ Project-URL: Repository, https://github.com/Samandar-Komilov/flowsurgeon
21
+ Project-URL: Issues, https://github.com/Samandar-Komilov/flowsurgeon/issues
22
+ Description-Content-Type: text/markdown
23
+
24
+ # FlowSurgeon
25
+
26
+ Framework-agnostic profiling middleware for Python — works with **Flask** (WSGI) and **FastAPI / Starlette** (ASGI) out of the box.
27
+
28
+ FlowSurgeon injects a lightweight debug panel into every HTML response and stores request history in a local SQLite database, giving you timing, headers, and status codes without touching your application code.
29
+
30
+ ## Features
31
+
32
+ - Zero required dependencies — pure stdlib
33
+ - WSGI middleware (`FlowSurgeonWSGI`) for Flask, Django, and any WSGI app
34
+ - ASGI middleware (`FlowSurgeonASGI`) for FastAPI, Starlette, and any ASGI app
35
+ - `FlowSurgeon()` factory — auto-detects WSGI vs ASGI
36
+ - Inline debug panel injected into HTML responses
37
+ - Built-in history UI at `/__flowsurgeon__/`
38
+ - SQLite persistence with auto-pruning
39
+ - Sensitive header redaction (`Authorization`, `Cookie`)
40
+ - `FLOWSURGEON_ENABLED` environment variable kill switch
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install flowsurgeon
46
+ ```
47
+
48
+ ## Quick start
49
+
50
+ ### FastAPI (ASGI)
51
+
52
+ ```python
53
+ from fastapi import FastAPI
54
+ from flowsurgeon import FlowSurgeon, Config
55
+
56
+ _app = FastAPI()
57
+ app = FlowSurgeon(_app, config=Config(enabled=True))
58
+
59
+ @_app.get("/")
60
+ async def index():
61
+ return {"hello": "world"}
62
+ ```
63
+
64
+ Run with uvicorn:
65
+
66
+ ```bash
67
+ uvicorn myapp:app
68
+ ```
69
+
70
+ ### Flask (WSGI)
71
+
72
+ ```python
73
+ from flask import Flask
74
+ from flowsurgeon import FlowSurgeon, Config
75
+
76
+ flask_app = Flask(__name__)
77
+ flask_app.wsgi_app = FlowSurgeon(
78
+ flask_app.wsgi_app,
79
+ config=Config(enabled=True),
80
+ )
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ ```python
86
+ from flowsurgeon import Config
87
+
88
+ Config(
89
+ enabled=True, # default: False (or FLOWSURGEON_ENABLED=1)
90
+ allowed_hosts=["127.0.0.1", "::1"], # hosts that see the panel
91
+ db_path="flowsurgeon.db", # SQLite file path
92
+ max_stored_requests=1000, # auto-prune threshold
93
+ debug_route="/__flowsurgeon__", # history UI prefix
94
+ strip_sensitive_headers=["authorization", "cookie", "set-cookie"],
95
+ )
96
+ ```
97
+
98
+ ## Debug UI
99
+
100
+ | Route | Description |
101
+ |---|---|
102
+ | `/__flowsurgeon__/` | Paginated request history |
103
+ | `/__flowsurgeon__/{id}` | Full detail for one request |
104
+
105
+ ## Roadmap
106
+
107
+ - v0.3.0 — SQL query tracking (SQLAlchemy, DB-API 2.0)
108
+ - v0.4.0 — CPU and memory profiling panel
109
+ - v0.5.0 — Log capture and headers panel
110
+ - v0.6.0 — Improved history/search UI
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,91 @@
1
+ # FlowSurgeon
2
+
3
+ Framework-agnostic profiling middleware for Python — works with **Flask** (WSGI) and **FastAPI / Starlette** (ASGI) out of the box.
4
+
5
+ FlowSurgeon injects a lightweight debug panel into every HTML response and stores request history in a local SQLite database, giving you timing, headers, and status codes without touching your application code.
6
+
7
+ ## Features
8
+
9
+ - Zero required dependencies — pure stdlib
10
+ - WSGI middleware (`FlowSurgeonWSGI`) for Flask, Django, and any WSGI app
11
+ - ASGI middleware (`FlowSurgeonASGI`) for FastAPI, Starlette, and any ASGI app
12
+ - `FlowSurgeon()` factory — auto-detects WSGI vs ASGI
13
+ - Inline debug panel injected into HTML responses
14
+ - Built-in history UI at `/__flowsurgeon__/`
15
+ - SQLite persistence with auto-pruning
16
+ - Sensitive header redaction (`Authorization`, `Cookie`)
17
+ - `FLOWSURGEON_ENABLED` environment variable kill switch
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install flowsurgeon
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ### FastAPI (ASGI)
28
+
29
+ ```python
30
+ from fastapi import FastAPI
31
+ from flowsurgeon import FlowSurgeon, Config
32
+
33
+ _app = FastAPI()
34
+ app = FlowSurgeon(_app, config=Config(enabled=True))
35
+
36
+ @_app.get("/")
37
+ async def index():
38
+ return {"hello": "world"}
39
+ ```
40
+
41
+ Run with uvicorn:
42
+
43
+ ```bash
44
+ uvicorn myapp:app
45
+ ```
46
+
47
+ ### Flask (WSGI)
48
+
49
+ ```python
50
+ from flask import Flask
51
+ from flowsurgeon import FlowSurgeon, Config
52
+
53
+ flask_app = Flask(__name__)
54
+ flask_app.wsgi_app = FlowSurgeon(
55
+ flask_app.wsgi_app,
56
+ config=Config(enabled=True),
57
+ )
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ```python
63
+ from flowsurgeon import Config
64
+
65
+ Config(
66
+ enabled=True, # default: False (or FLOWSURGEON_ENABLED=1)
67
+ allowed_hosts=["127.0.0.1", "::1"], # hosts that see the panel
68
+ db_path="flowsurgeon.db", # SQLite file path
69
+ max_stored_requests=1000, # auto-prune threshold
70
+ debug_route="/__flowsurgeon__", # history UI prefix
71
+ strip_sensitive_headers=["authorization", "cookie", "set-cookie"],
72
+ )
73
+ ```
74
+
75
+ ## Debug UI
76
+
77
+ | Route | Description |
78
+ |---|---|
79
+ | `/__flowsurgeon__/` | Paginated request history |
80
+ | `/__flowsurgeon__/{id}` | Full detail for one request |
81
+
82
+ ## Roadmap
83
+
84
+ - v0.3.0 — SQL query tracking (SQLAlchemy, DB-API 2.0)
85
+ - v0.4.0 — CPU and memory profiling panel
86
+ - v0.5.0 — Log capture and headers panel
87
+ - v0.6.0 — Improved history/search UI
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "flowsurgeon"
3
+ version = "0.2.0"
4
+ description = "FlowSurgeon — framework-agnostic profiling middleware for Python (WSGI & ASGI)."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "Samandar-Komilov", email = "voidpointer07@gmail.com" }]
8
+ requires-python = ">=3.12"
9
+ dependencies = []
10
+ keywords = ["wsgi", "asgi", "middleware", "profiling", "debugging", "fastapi", "flask", "starlette"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware",
19
+ "Topic :: Software Development :: Debuggers",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/Samandar-Komilov/flowsurgeon"
25
+ Repository = "https://github.com/Samandar-Komilov/flowsurgeon"
26
+ Issues = "https://github.com/Samandar-Komilov/flowsurgeon/issues"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.9.17,<0.10.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest>=9.0.2",
42
+ "pytest-asyncio>=1.3.0",
43
+ "pytest-cov>=7.0.0",
44
+ ]
45
+ examples = [
46
+ "fastapi>=0.135.1",
47
+ "flask>=3.1.3",
48
+ "uvicorn>=0.41.0",
49
+ ]
@@ -0,0 +1,39 @@
1
+ """FlowSurgeon — framework-agnostic profiling middleware for Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+
7
+ from flowsurgeon.core.config import Config
8
+ from flowsurgeon.core.records import RequestRecord
9
+ from flowsurgeon.middleware.asgi import FlowSurgeonASGI
10
+ from flowsurgeon.middleware.wsgi import FlowSurgeonWSGI
11
+ from flowsurgeon.storage.async_sqlite import AsyncSQLiteBackend
12
+ from flowsurgeon.storage.base import StorageBackend
13
+ from flowsurgeon.storage.sqlite import SQLiteBackend
14
+
15
+
16
+ def FlowSurgeon(app, *, config: Config | None = None, storage=None):
17
+ """Auto-detect WSGI vs ASGI and wrap *app* with the appropriate middleware.
18
+
19
+ Detection is based on whether ``app.__call__`` is a coroutine function.
20
+ FastAPI / Starlette apps are detected as ASGI; plain WSGI callables
21
+ (Flask, Django, etc.) are wrapped with :class:`FlowSurgeonWSGI`.
22
+ """
23
+ if inspect.iscoroutinefunction(app) or inspect.iscoroutinefunction(getattr(app, "__call__", None)):
24
+ return FlowSurgeonASGI(app, config=config, storage=storage)
25
+ return FlowSurgeonWSGI(app, config=config, storage=storage)
26
+
27
+
28
+ __all__ = [
29
+ "AsyncSQLiteBackend",
30
+ "Config",
31
+ "FlowSurgeon",
32
+ "FlowSurgeonASGI",
33
+ "FlowSurgeonWSGI",
34
+ "RequestRecord",
35
+ "SQLiteBackend",
36
+ "StorageBackend",
37
+ ]
38
+
39
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from flowsurgeon.core.config import Config
2
+ from flowsurgeon.core.records import RequestRecord
3
+
4
+ __all__ = ["Config", "RequestRecord"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class Config:
9
+ """FlowSurgeon configuration.
10
+
11
+ Parameters
12
+ ----------
13
+ enabled:
14
+ Master switch. Defaults to False; can be overridden by the
15
+ FLOWSURGEON_ENABLED environment variable.
16
+ allowed_hosts:
17
+ Only serve the debug panel to requests from these hosts/IPs.
18
+ Defaults to localhost addresses only.
19
+ db_path:
20
+ Path to the SQLite database file used for persistence.
21
+ max_stored_requests:
22
+ Maximum number of request records to keep in the database.
23
+ Older records are pruned automatically.
24
+ debug_route:
25
+ URL prefix for the built-in debug UI.
26
+ strip_sensitive_headers:
27
+ Header names whose values will be redacted before storage.
28
+ """
29
+
30
+ enabled: bool = field(default_factory=lambda: _env_bool("FLOWSURGEON_ENABLED", False))
31
+ allowed_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1", "::1", "localhost"])
32
+ db_path: str = "flowsurgeon.db"
33
+ max_stored_requests: int = 1000
34
+ debug_route: str = "/__flowsurgeon__"
35
+ strip_sensitive_headers: list[str] = field(
36
+ default_factory=lambda: ["authorization", "cookie", "set-cookie"]
37
+ )
38
+
39
+
40
+ def _env_bool(name: str, default: bool) -> bool:
41
+ val = os.environ.get(name)
42
+ if val is None:
43
+ return default
44
+ return val.lower() in ("1", "true", "yes")
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ @dataclass
9
+ class RequestRecord:
10
+ """Captured data for a single HTTP request."""
11
+
12
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
13
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
14
+ method: str = ""
15
+ path: str = ""
16
+ query_string: str = ""
17
+ status_code: int = 0
18
+ duration_ms: float = 0.0
19
+ request_headers: dict[str, str] = field(default_factory=dict)
20
+ response_headers: dict[str, str] = field(default_factory=dict)
21
+ client_host: str = ""
@@ -0,0 +1,3 @@
1
+ from flowsurgeon.middleware.wsgi import FlowSurgeonWSGI
2
+
3
+ __all__ = ["FlowSurgeonWSGI"]
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Awaitable, Callable
5
+
6
+ from flowsurgeon.core.config import Config
7
+ from flowsurgeon.core.records import RequestRecord
8
+ from flowsurgeon.storage.async_sqlite import AsyncSQLiteBackend
9
+ from flowsurgeon.ui.panel import render_detail_page, render_history_page, render_panel
10
+
11
+ # ASGI type aliases
12
+ Scope = dict
13
+ Receive = Callable[[], Awaitable[dict]]
14
+ Send = Callable[[dict], Awaitable[None]]
15
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
16
+
17
+ _HTML_CONTENT_TYPES = ("text/html",)
18
+
19
+
20
+ class FlowSurgeonASGI:
21
+ """ASGI middleware that profiles requests and injects a debug panel.
22
+
23
+ Parameters
24
+ ----------
25
+ app:
26
+ The wrapped ASGI application.
27
+ config:
28
+ FlowSurgeon configuration. Defaults to :class:`Config` with all
29
+ defaults applied.
30
+ storage:
31
+ Async storage backend. Defaults to :class:`AsyncSQLiteBackend`
32
+ pointed at ``config.db_path``.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ app: ASGIApp,
38
+ config: Config | None = None,
39
+ storage: AsyncSQLiteBackend | None = None,
40
+ ) -> None:
41
+ self._app = app
42
+ self._config = config or Config()
43
+ self._storage: AsyncSQLiteBackend = storage or AsyncSQLiteBackend(self._config.db_path)
44
+
45
+ # ------------------------------------------------------------------
46
+ # ASGI entry point
47
+ # ------------------------------------------------------------------
48
+
49
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
50
+ if scope["type"] == "lifespan":
51
+ await self._handle_lifespan(scope, receive, send)
52
+ return
53
+
54
+ if scope["type"] != "http":
55
+ await self._app(scope, receive, send)
56
+ return
57
+
58
+ if not self._config.enabled:
59
+ await self._app(scope, receive, send)
60
+ return
61
+
62
+ client_host = _client_host(scope)
63
+ if not self._is_allowed(client_host):
64
+ await self._app(scope, receive, send)
65
+ return
66
+
67
+ path = scope.get("path", "/")
68
+ debug_route = self._config.debug_route
69
+
70
+ if path == debug_route or path == debug_route + "/":
71
+ await self._serve_history(send)
72
+ return
73
+ if path.startswith(debug_route + "/"):
74
+ request_id = path[len(debug_route) + 1 :]
75
+ await self._serve_detail(request_id, send)
76
+ return
77
+
78
+ await self._profile_request(scope, receive, send, client_host, path)
79
+
80
+ # ------------------------------------------------------------------
81
+ # Lifespan
82
+ # ------------------------------------------------------------------
83
+
84
+ async def _handle_lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
85
+ async def receive_wrapper() -> dict:
86
+ message = await receive()
87
+ if message["type"] == "lifespan.startup":
88
+ await self._storage.start()
89
+ return message
90
+
91
+ async def send_wrapper(message: dict) -> None:
92
+ if message["type"] == "lifespan.shutdown.complete":
93
+ await self._storage.close()
94
+ await send(message)
95
+
96
+ await self._app(scope, receive_wrapper, send_wrapper)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Debug UI routes
100
+ # ------------------------------------------------------------------
101
+
102
+ async def _serve_history(self, send: Send) -> None:
103
+ records = await self._storage.list_recent(limit=100)
104
+ body = render_history_page(records, self._config.debug_route).encode()
105
+ await send(
106
+ {
107
+ "type": "http.response.start",
108
+ "status": 200,
109
+ "headers": [
110
+ (b"content-type", b"text/html; charset=utf-8"),
111
+ (b"content-length", str(len(body)).encode()),
112
+ ],
113
+ }
114
+ )
115
+ await send({"type": "http.response.body", "body": body})
116
+
117
+ async def _serve_detail(self, request_id: str, send: Send) -> None:
118
+ record = await self._storage.get(request_id)
119
+ if record is None:
120
+ body = b"<h1>Not found</h1>"
121
+ await send(
122
+ {
123
+ "type": "http.response.start",
124
+ "status": 404,
125
+ "headers": [
126
+ (b"content-type", b"text/html; charset=utf-8"),
127
+ (b"content-length", str(len(body)).encode()),
128
+ ],
129
+ }
130
+ )
131
+ await send({"type": "http.response.body", "body": body})
132
+ return
133
+ body = render_detail_page(record, self._config.debug_route).encode()
134
+ await send(
135
+ {
136
+ "type": "http.response.start",
137
+ "status": 200,
138
+ "headers": [
139
+ (b"content-type", b"text/html; charset=utf-8"),
140
+ (b"content-length", str(len(body)).encode()),
141
+ ],
142
+ }
143
+ )
144
+ await send({"type": "http.response.body", "body": body})
145
+
146
+ # ------------------------------------------------------------------
147
+ # Request profiling
148
+ # ------------------------------------------------------------------
149
+
150
+ async def _profile_request(
151
+ self,
152
+ scope: Scope,
153
+ receive: Receive,
154
+ send: Send,
155
+ client_host: str,
156
+ path: str,
157
+ ) -> None:
158
+ raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
159
+ qs = scope.get("query_string", b"").decode("latin-1")
160
+
161
+ record = RequestRecord(
162
+ method=scope.get("method", "GET"),
163
+ path=path,
164
+ query_string=qs,
165
+ client_host=client_host,
166
+ request_headers=_asgi_headers_to_dict(
167
+ raw_headers, self._config.strip_sensitive_headers
168
+ ),
169
+ )
170
+
171
+ # Capture response start + body chunks, detect if HTML to decide strategy
172
+ start_message: dict | None = None
173
+ is_html = False
174
+ forwarding = False # True when we're in pass-through mode (non-HTML)
175
+ body_chunks: list[bytes] = []
176
+
177
+ async def capturing_send(message: dict) -> None:
178
+ nonlocal start_message, is_html, forwarding
179
+
180
+ if message["type"] == "http.response.start":
181
+ start_message = message
182
+ ct = _get_asgi_header(message.get("headers", []), b"content-type") or b""
183
+ is_html = any(h.encode() in ct for h in _HTML_CONTENT_TYPES)
184
+ if not is_html:
185
+ # Non-HTML: forward start immediately and switch to pass-through
186
+ await send(message)
187
+ forwarding = True
188
+ return
189
+
190
+ if message["type"] == "http.response.body":
191
+ if forwarding:
192
+ await send(message)
193
+ else:
194
+ body_chunks.append(message.get("body", b""))
195
+
196
+ t0 = time.perf_counter()
197
+ await self._app(scope, receive, capturing_send)
198
+ duration_ms = (time.perf_counter() - t0) * 1000
199
+
200
+ if start_message is None:
201
+ return # degenerate app
202
+
203
+ status_code: int = start_message["status"]
204
+ resp_raw_headers: list[tuple[bytes, bytes]] = start_message.get("headers", [])
205
+
206
+ record.status_code = status_code
207
+ record.duration_ms = duration_ms
208
+ record.response_headers = _asgi_headers_to_dict(
209
+ resp_raw_headers, self._config.strip_sensitive_headers
210
+ )
211
+
212
+ # Persist asynchronously (non-blocking)
213
+ await self._storage.enqueue(record, self._config.max_stored_requests)
214
+
215
+ if forwarding:
216
+ # Already forwarded — nothing more to do
217
+ return
218
+
219
+ # HTML path: inject panel, then forward full response
220
+ body = b"".join(body_chunks)
221
+ panel_html = render_panel(record, self._config.debug_route).encode()
222
+ if b"</body>" in body:
223
+ body = body.replace(b"</body>", panel_html + b"</body>", 1)
224
+ else:
225
+ body += panel_html
226
+
227
+ # Rebuild headers with updated Content-Length
228
+ updated_headers = [(k, v) for k, v in resp_raw_headers if k.lower() != b"content-length"]
229
+ updated_headers.append((b"content-length", str(len(body)).encode()))
230
+
231
+ await send(
232
+ {
233
+ "type": "http.response.start",
234
+ "status": status_code,
235
+ "headers": updated_headers,
236
+ }
237
+ )
238
+ await send({"type": "http.response.body", "body": body})
239
+
240
+ # ------------------------------------------------------------------
241
+ # Helpers
242
+ # ------------------------------------------------------------------
243
+
244
+ def _is_allowed(self, host: str) -> bool:
245
+ return host in self._config.allowed_hosts
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Module-level helpers
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def _client_host(scope: Scope) -> str:
254
+ headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
255
+ for name, value in headers:
256
+ if name.lower() == b"x-forwarded-for":
257
+ return value.decode("latin-1").split(",")[0].strip()
258
+ client = scope.get("client")
259
+ return client[0] if client else ""
260
+
261
+
262
+ def _asgi_headers_to_dict(headers: list[tuple[bytes, bytes]], strip: list[str]) -> dict[str, str]:
263
+ result: dict[str, str] = {}
264
+ for name_bytes, value_bytes in headers:
265
+ name = name_bytes.decode("latin-1").lower()
266
+ value = value_bytes.decode("latin-1")
267
+ result[name] = "[redacted]" if name in strip else value
268
+ return result
269
+
270
+
271
+ def _get_asgi_header(headers: list[tuple[bytes, bytes]], name: bytes) -> bytes | None:
272
+ name_lower = name.lower()
273
+ for k, v in headers:
274
+ if k.lower() == name_lower:
275
+ return v
276
+ return None