├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── publish.yml │ └── test-suite.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── debug_toolbar ├── __init__.py ├── api.py ├── dependencies.py ├── middleware.py ├── panels │ ├── __init__.py │ ├── headers.py │ ├── logging.py │ ├── profiling.py │ ├── redirects.py │ ├── request.py │ ├── routes.py │ ├── settings.py │ ├── sql.py │ ├── sqlalchemy.py │ ├── timer.py │ ├── tortoise.py │ └── versions.py ├── py.typed ├── responses.py ├── settings.py ├── statics │ ├── css │ │ ├── print.css │ │ └── toolbar.css │ ├── img │ │ ├── icon-green.svg │ │ └── icon-white.svg │ └── js │ │ ├── redirect.js │ │ ├── refresh.js │ │ ├── timer.js │ │ ├── toolbar.js │ │ ├── utils.js │ │ └── versions.js ├── templates │ ├── base.html │ ├── includes │ │ ├── panel_button.html │ │ └── panel_content.html │ ├── panels │ │ ├── headers.html │ │ ├── logging.html │ │ ├── profiling.html │ │ ├── request.html │ │ ├── routes.html │ │ ├── settings.html │ │ ├── sql.html │ │ ├── timer.html │ │ └── versions.html │ └── redirect.html ├── toolbar.py ├── types.py └── utils.py ├── docs ├── CNAME ├── changelog.md ├── css │ └── styles.css ├── img │ ├── Swagger.png │ ├── favicon.ico │ ├── logo.png │ ├── panels │ │ ├── Headers.png │ │ ├── Logging.png │ │ ├── Profiling.png │ │ ├── Request.png │ │ ├── Routes.png │ │ ├── SQLAlchemy.png │ │ ├── Settings.png │ │ ├── Timer.png │ │ └── Versions.png │ └── tab.png ├── index.md ├── overrides │ └── main.html ├── panels │ ├── default.md │ ├── dev.md │ ├── panel.md │ └── sql.md ├── settings.md └── src │ ├── dev │ ├── panels.py │ └── quickstart.py │ ├── panels │ ├── profiling.py │ ├── settings.py │ ├── sqlalchemy │ │ ├── add_engines.py │ │ └── panel.py │ └── tortoise.py │ └── quickstart.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── lint └── test └── tests ├── __init__.py ├── conftest.py ├── mark.py ├── panels ├── __init__.py ├── sqlalchemy │ ├── __init__.py │ ├── conftest.py │ ├── crud.py │ ├── database.py │ ├── models.py │ └── test_sqlalchemy.py ├── test_headers.py ├── test_logging.py ├── test_profiling.py ├── test_redirects.py ├── test_request.py ├── test_routes.py ├── test_settings.py ├── test_timer.py ├── test_versions.py └── tortoise │ ├── __init__.py │ ├── conftest.py │ ├── crud.py │ ├── models.py │ └── test_tortoise.py ├── templates └── index.html ├── test_api.py ├── test_middleware.py └── testclient.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | groups: 9 | python-packages: 10 | patterns: 11 | - '*' 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | cache: pip 16 | python-version: "3.12" 17 | 18 | - name: Install dependencies 19 | run: pip install .[doc] 20 | 21 | - name: Deploy to GitHub Pages 22 | run: mkdocs gh-deploy --force 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | 17 | - name: Install dependencies 18 | run: pip install build 19 | 20 | - name: Build 21 | run: python -m build 22 | 23 | - name: Publish to PyPI 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | with: 26 | password: ${{ secrets.PYPI_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/test-suite.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | push: 5 | 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | tests: 11 | name: Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v5 21 | with: 22 | cache: pip 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: pip install -e .[test] 27 | 28 | - name: Tests 29 | run: scripts/test 30 | 31 | - name: Upload coverage 32 | uses: codecov/codecov-action@v4 33 | env: 34 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.*~ 2 | *.DS_Store 3 | *.box 4 | *.log 5 | *.pyc 6 | 7 | __pycache__/ 8 | .coverage 9 | .mypy_cache/ 10 | .pytest_cache/ 11 | .vscode/ 12 | coverage.xml 13 | dist/ 14 | htmlcov/ 15 | site/ 16 | venv* 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 0.6.3 4 | 5 | * Added support to SQLAlchemy `AsyncSession` and multiple binds 6 | 7 | ### 0.6.2 8 | 9 | * **SQLAlchemyPanel**: Handled HTTPException from dependencies 10 | * Removed pydantic future annotations 11 | 12 | ### 0.6.1 13 | 14 | * Removed deprecated `fastapi_astack` scope 15 | * Fixed `0.6` release tag. 16 | 17 | ### 0.6.0 18 | 19 | * **SQLAlchemyPanel**: Added `async_exit_stack` arg to `solve_dependencies` function 20 | * **DebugToolbarMiddleware**: Removed `settings.ALLOWED_IPS` in favor of `settings.ALLOWED_HOSTS` 21 | * **VersionsPanel**: Removed `pkg_resources` in favor of `importlib.metadata` 22 | * Added ruff and bandit and removed black and isort 23 | * Removed deprecated `on_event` 24 | * Added minor improvements 25 | 26 | ### 0.5.0 27 | 28 | * Added Pydantic v2 support 29 | * Removed Pydantic v1 support 30 | * Removed `PydanticPanel` 31 | 32 | ### 0.4.0 33 | 34 | * Fixed middleware `url_path_for` 35 | * Improved SQLAlchemy panel 36 | 37 | ### 0.3.2 38 | 39 | * Fixed response body stream 40 | 41 | ### 0.3.1 42 | 43 | * Fixed pyproject.toml, added package data 44 | * Improved panel templates 45 | * Fixed profiling on Safari browser 46 | 47 | ### 0.3.0 48 | 49 | * Added refresh cookie system and `JSON.parse` swap removed 50 | * Fixed SQL query encoding 51 | * Fixed `SQLAlchemyPanel` , added missing `fastapi_astack` to scope (`fastapi >= 0.74.0`) 52 | * Added `SQLAlchemyPanel.add_engines` method 53 | * Added `tortoise-orm >= 0.19.0` support 54 | * Fixed `VersionsPanel` JS, package home can be null 55 | 56 | ### 0.2.1 57 | 58 | * Added `PydanticPanel` 59 | * Removed `current_thread` in favor of `get_ident` 60 | * Added anyio task groups 61 | * Removed `get_running_loop` in favor of `get_event_loop` 62 | * Improved tables styles 63 | 64 | ### 0.2.0 65 | 66 | * Fixed `ThreadPoolExecutor` for all sync endpoints 67 | * Added cookie-based refresh 68 | * Added exception handling for dependency resolution 69 | * Added minor improvements to `VersionPanel` 70 | 71 | ### 0.1.3 72 | 73 | * Added `TortoisePanel` 74 | 75 | ### 0.1.2 76 | 77 | * Removed SQL compiled query in favor of statement params 78 | * Added SQLAlchemy unregister 79 | * Added `SQLPanel` base class 80 | 81 | ### 0.1.1 82 | 83 | * Improved dependency resolution 84 | * Added minor improvements 85 | 86 | ### 0.1.0 87 | 88 | * Added `SQLAlchemyPanel` 89 | * Added `LOGGING_COLORS` to panel templates 90 | * Minor improvements 91 | 92 | ### 0.0.6 93 | 94 | * Improved `VersionsPanel` script 95 | * Added docs 96 | 97 | ### 0.0.5 98 | 99 | * Fixed multiple profilers on the same thread 100 | * Fixed `VersionsPanel` Pypi url 101 | 102 | ### 0.0.4 103 | 104 | * Added pypi details to `VersionsPanel` 105 | * Improved assets 106 | * Added `LOGGING_COLORS` 107 | * Highlighted matched endpoint 108 | 109 | ### 0.0.3 110 | 111 | * Sorted routes by path 112 | 113 | ### 0.0.2 114 | 115 | * Added mounted apps support (e.g. ariadne.asgi.GraphQL) 116 | 117 | ### 0.0.1 118 | 119 | * 📦 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Rob Hudson and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![FastAPI](https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/main/debug_toolbar/statics/img/icon-green.svg) Debug Toolbar 2 | 3 |

4 | 5 | FastAPI Debug Toolbar 6 | 7 |

8 |

9 | 🐞A debug toolbar for FastAPI based on the original django-debug-toolbar.🐞 10 |
Swagger UI & GraphQL are supported. 11 |

12 |

13 | 14 | Test 15 | 16 | 17 | Coverage 18 | 19 | 20 | Codacy 21 | 22 | 23 | Package version 24 | 25 |

26 | 27 | 28 | --- 29 | 30 | **Documentation**: [https://fastapi-debug-toolbar.domake.io](https://fastapi-debug-toolbar.domake.io) 31 | 32 | --- 33 | 34 | ## Installation 35 | 36 | ```sh 37 | pip install fastapi-debug-toolbar 38 | ``` 39 | 40 | ## Quickstart 41 | 42 | Add `DebugToolbarMiddleware` middleware to your FastAPI application: 43 | 44 | ```py 45 | from debug_toolbar.middleware import DebugToolbarMiddleware 46 | from fastapi import FastAPI 47 | 48 | app = FastAPI(debug=True) 49 | app.add_middleware(DebugToolbarMiddleware) 50 | ``` 51 | 52 | ## SQLAlchemy 53 | 54 | Please make sure to use the *"Dependency Injection"* system as described in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency) and add the `SQLAlchemyPanel` to your panel list: 55 | 56 | ```py 57 | app.add_middleware( 58 | DebugToolbarMiddleware, 59 | panels=["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"], 60 | ) 61 | ``` 62 | 63 | ## Tortoise ORM 64 | 65 | Add the `TortoisePanel` to your panel list: 66 | 67 | ```py 68 | app.add_middleware( 69 | DebugToolbarMiddleware, 70 | panels=["debug_toolbar.panels.tortoise.TortoisePanel"], 71 | ) 72 | ``` 73 | -------------------------------------------------------------------------------- /debug_toolbar/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.3" 2 | -------------------------------------------------------------------------------- /debug_toolbar/api.py: -------------------------------------------------------------------------------- 1 | import html 2 | import typing as t 3 | 4 | from fastapi import Request 5 | 6 | from debug_toolbar.toolbar import DebugToolbar 7 | 8 | 9 | def render_panel(request: Request, store_id: str, panel_id: str) -> t.Any: 10 | toolbar = DebugToolbar.fetch(store_id) 11 | 12 | if toolbar is None: 13 | content = ( 14 | "Data for this panel isn't available anymore. " 15 | "Please reload the page and retry." 16 | ) 17 | content = f"

{html.escape(content)}

" 18 | scripts = [] 19 | else: 20 | panel = toolbar.get_panel_by_id(panel_id) 21 | content, scripts = panel.content, panel.scripts 22 | 23 | return {"content": content, "scripts": scripts} 24 | -------------------------------------------------------------------------------- /debug_toolbar/dependencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from contextlib import AsyncExitStack 5 | 6 | from fastapi import HTTPException, Request 7 | from fastapi.dependencies.utils import solve_dependencies 8 | 9 | 10 | async def get_dependencies(request: Request) -> dict[str, t.Any] | None: 11 | route = request["route"] 12 | 13 | if hasattr(route, "dependant"): 14 | try: 15 | solved_result = await solve_dependencies( 16 | request=request, 17 | dependant=route.dependant, 18 | dependency_overrides_provider=route.dependency_overrides_provider, 19 | async_exit_stack=AsyncExitStack(), 20 | ) 21 | except HTTPException: 22 | pass 23 | else: 24 | return solved_result[0] 25 | return None 26 | -------------------------------------------------------------------------------- /debug_toolbar/middleware.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import re 4 | import typing as t 5 | from urllib import parse 6 | 7 | from anyio import CapacityLimiter 8 | from anyio.lowlevel import RunVar 9 | from fastapi import APIRouter, HTTPException, Request, Response, status 10 | from fastapi.responses import StreamingResponse 11 | from fastapi.staticfiles import StaticFiles 12 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 13 | from starlette.routing import NoMatchFound 14 | from starlette.types import ASGIApp 15 | 16 | from debug_toolbar.api import render_panel 17 | from debug_toolbar.settings import DebugToolbarSettings 18 | from debug_toolbar.toolbar import DebugToolbar 19 | from debug_toolbar.utils import import_string, matched_route 20 | 21 | 22 | def show_toolbar(request: Request, settings: DebugToolbarSettings) -> bool: 23 | if request.client is not None and settings.ALLOWED_HOSTS is not None: 24 | return request.app.debug and request.client.host in settings.ALLOWED_HOSTS 25 | return request.app.debug 26 | 27 | 28 | class DebugToolbarMiddleware(BaseHTTPMiddleware): 29 | def __init__(self, app: ASGIApp, **settings: t.Any) -> None: 30 | super().__init__(app) 31 | self.settings = DebugToolbarSettings(**settings) 32 | self.show_toolbar = import_string(self.settings.SHOW_TOOLBAR_CALLBACK) 33 | self.router: APIRouter = app # type: ignore[assignment] 34 | 35 | while not isinstance(self.router, APIRouter): 36 | self.router = self.router.app 37 | try: 38 | self.router.url_path_for("debug_toolbar.render_panel") 39 | except NoMatchFound: 40 | self.init_toolbar() 41 | 42 | def init_toolbar(self) -> None: 43 | self.router.get( 44 | self.settings.API_URL, 45 | name="debug_toolbar.render_panel", 46 | include_in_schema=False, 47 | )(self.require_show_toolbar(render_panel)) 48 | 49 | self.router.mount( 50 | self.settings.STATIC_URL, 51 | StaticFiles(packages=[__package__]), 52 | name="debug_toolbar.static", 53 | ) 54 | 55 | _default_thread_limiter: RunVar[CapacityLimiter] = RunVar( 56 | "_default_thread_limiter" 57 | ) 58 | _default_thread_limiter.set(CapacityLimiter(1)) 59 | 60 | async def dispatch( 61 | self, 62 | request: Request, 63 | call_next: RequestResponseEndpoint, 64 | ) -> Response: 65 | request.scope["route"] = matched_route(request) 66 | 67 | if ( 68 | not request["route"] 69 | or not self.show_toolbar(request, self.settings) 70 | or self.settings.API_URL in request.url.path 71 | ): 72 | return await call_next(request) 73 | 74 | toolbar = DebugToolbar(request, call_next, self.settings) 75 | response = t.cast(StreamingResponse, await toolbar.process_request(request)) 76 | content_type = response.headers.get("Content-Type", "") 77 | is_html = content_type.startswith("text/html") 78 | 79 | if not (is_html or content_type == "application/json") or ( 80 | "gzip" in response.headers.get("Accept-Encoding", "") 81 | ): 82 | return response 83 | 84 | await toolbar.record_stats(response) 85 | await toolbar.record_server_timing(response) 86 | toolbar.generate_server_timing_header(response) 87 | 88 | if is_html: 89 | body = b"" 90 | 91 | async for chunk in response.body_iterator: 92 | if not isinstance(chunk, bytes): 93 | chunk = chunk.encode(response.charset) 94 | body += chunk 95 | 96 | decoded = body.decode(response.charset) 97 | pattern = re.escape(self.settings.INSERT_BEFORE) 98 | bits = re.split(pattern, decoded, flags=re.IGNORECASE) 99 | 100 | if len(bits) > 1: 101 | bits[-2] += toolbar.render_toolbar() 102 | body = self.settings.INSERT_BEFORE.join(bits).encode(response.charset) 103 | response.headers["Content-Length"] = str(len(body)) 104 | 105 | async def stream() -> t.AsyncGenerator[bytes, None]: 106 | yield body 107 | 108 | response.body_iterator = stream() 109 | else: 110 | data = parse.quote(json.dumps(toolbar.refresh())) 111 | response.set_cookie(key="dtRefresh", value=data) 112 | 113 | return response 114 | 115 | def require_show_toolbar( 116 | self, 117 | f: t.Callable[..., t.Any], 118 | ) -> t.Callable[[t.Any], t.Any]: 119 | @functools.wraps(f) 120 | def decorator(request: Request, *args: t.Any, **kwargs: t.Any) -> t.Any: 121 | if not self.show_toolbar(request, self.settings): 122 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 123 | return f(request, *args, **kwargs) 124 | 125 | return decorator 126 | -------------------------------------------------------------------------------- /debug_toolbar/panels/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from fastapi import Request, Response 6 | from starlette.middleware.base import RequestResponseEndpoint 7 | 8 | from debug_toolbar.types import ServerTiming, Stats 9 | from debug_toolbar.utils import get_name_from_obj 10 | 11 | if t.TYPE_CHECKING: 12 | from debug_toolbar.toolbar import DebugToolbar 13 | 14 | 15 | class Panel: 16 | has_content: bool = True 17 | 18 | def __init__( 19 | self, 20 | toolbar: "DebugToolbar", 21 | call_next: RequestResponseEndpoint, 22 | ) -> None: 23 | self.toolbar = toolbar 24 | self.call_next = call_next 25 | 26 | @property 27 | def panel_id(self) -> str: 28 | return self.__class__.__name__ 29 | 30 | @property 31 | def enabled(self) -> bool: 32 | disabled_panels = self.toolbar.settings.DISABLE_PANELS 33 | panel_path = get_name_from_obj(self) 34 | 35 | disable_panel = ( 36 | panel_path in disabled_panels 37 | or panel_path.replace(".panel.", ".") in disabled_panels 38 | ) 39 | if disable_panel: 40 | default = "off" 41 | else: 42 | default = "on" 43 | 44 | return self.toolbar.request.cookies.get(f"dt{self.panel_id}", default) == "on" 45 | 46 | @property 47 | def nav_title(self) -> str: 48 | return self.title 49 | 50 | @property 51 | def nav_subtitle(self) -> str: 52 | return "" 53 | 54 | @property 55 | def title(self) -> str: 56 | raise NotImplementedError 57 | 58 | @property 59 | def template(self) -> str: 60 | raise NotImplementedError 61 | 62 | @property 63 | def content(self) -> str: 64 | if self.has_content: 65 | return self.render(**self.get_stats()) 66 | return "" 67 | 68 | def render(self, **context: t.Any) -> str: 69 | return self.toolbar.render(self.template, **context) 70 | 71 | def url_for(self, name: str, **path_params: t.Any) -> str: 72 | return str(self.toolbar.request.url_for(name, **path_params)) 73 | 74 | @property 75 | def scripts(self) -> list[str]: 76 | return [] 77 | 78 | async def process_request(self, request: Request) -> Response: 79 | return await self.call_next(request) 80 | 81 | async def generate_stats(self, request: Request, response: Response) -> Stats: 82 | return {} 83 | 84 | def get_stats(self) -> Stats: 85 | return self.toolbar.stats.get(self.panel_id, {}) 86 | 87 | async def record_stats(self, request: Request, response: Response) -> None: 88 | stats = await self.generate_stats(request, response) 89 | 90 | if stats is not None: 91 | self.toolbar.stats.setdefault(self.panel_id, {}).update(stats) 92 | 93 | async def generate_server_timing( 94 | self, 95 | request: Request, 96 | response: Response, 97 | ) -> ServerTiming: 98 | return [] 99 | 100 | def get_server_timing_stats(self) -> ServerTiming: 101 | return self.toolbar.server_timing_stats.get(self.panel_id, []) 102 | 103 | async def record_server_timing(self, request: Request, response: Response) -> None: 104 | stats = await self.generate_server_timing(request, response) 105 | 106 | if stats is not None: 107 | st_stats = self.toolbar.server_timing_stats.setdefault(self.panel_id, []) 108 | st_stats += list(stats) 109 | -------------------------------------------------------------------------------- /debug_toolbar/panels/headers.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Response 2 | 3 | from debug_toolbar.panels import Panel 4 | from debug_toolbar.types import Stats 5 | 6 | 7 | class HeadersPanel(Panel): 8 | title = "Headers" 9 | template = "panels/headers.html" 10 | 11 | async def generate_stats(self, request: Request, response: Response) -> Stats: 12 | if "cookie" in (request_headers := dict(request.headers)): 13 | request_headers["cookie"] = "=> see Request panel" 14 | 15 | return { 16 | "request_headers": request_headers, 17 | "environ": request.scope.get("asgi", {}), 18 | "response_headers": response.headers, 19 | } 20 | -------------------------------------------------------------------------------- /debug_toolbar/panels/logging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | import typing as t 6 | 7 | from fastapi import Request, Response 8 | from starlette.concurrency import run_in_threadpool 9 | 10 | from debug_toolbar.panels import Panel 11 | from debug_toolbar.types import Stats 12 | from debug_toolbar.utils import is_coroutine, pluralize 13 | 14 | try: 15 | import threading 16 | except ImportError: 17 | threading = None # type: ignore[assignment] 18 | 19 | 20 | class LogCollector: 21 | def __init__(self) -> None: 22 | if threading is None: 23 | raise NotImplementedError( 24 | "threading module is not available, " 25 | "this panel cannot be used without it" 26 | ) 27 | self.collections: dict[int, list[dict[str, t.Any]]] = {} 28 | 29 | def get_collection(self, thread_id: int | None = None) -> list[dict[str, t.Any]]: 30 | if thread_id is None: 31 | thread_id = threading.get_ident() 32 | if thread_id not in self.collections: 33 | self.collections[thread_id] = [] 34 | return self.collections[thread_id] 35 | 36 | def clear_collection(self, thread_id: int | None = None) -> None: 37 | if thread_id is None: 38 | thread_id = threading.get_ident() 39 | if thread_id in self.collections: 40 | del self.collections[thread_id] 41 | 42 | def collect(self, item: dict[str, t.Any], thread_id: int | None = None) -> None: 43 | self.get_collection(thread_id).append(item) 44 | 45 | 46 | class ThreadTrackingHandler(logging.Handler): 47 | def __init__(self, collector: LogCollector) -> None: 48 | logging.Handler.__init__(self) 49 | self.collector = collector 50 | 51 | def emit(self, record: logging.LogRecord) -> None: 52 | try: 53 | message = record.getMessage() 54 | except Exception: 55 | message = "[Could not get log message]" 56 | 57 | self.collector.collect( 58 | { 59 | "message": message, 60 | "time": datetime.datetime.fromtimestamp(record.created), 61 | "level": record.levelname, 62 | "file": record.pathname, 63 | "line": record.lineno, 64 | "channel": record.name, 65 | } 66 | ) 67 | 68 | 69 | collector = LogCollector() 70 | logging_handler = ThreadTrackingHandler(collector) 71 | logging.root.addHandler(logging_handler) 72 | 73 | 74 | class LoggingPanel(Panel): 75 | nav_title = "Logging" 76 | title = "Log messages" 77 | template = "panels/logging.html" 78 | 79 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: 80 | super().__init__(*args, **kwargs) 81 | self._records: dict[t.Any, list[dict[str, t.Any]]] = {} 82 | 83 | @property 84 | def nav_subtitle(self) -> str: 85 | stats = self.get_stats() 86 | record_count = len(stats["records"]) 87 | return f"{record_count} message{pluralize(record_count)}" 88 | 89 | async def process_request(self, request: Request) -> Response: 90 | if is_coroutine(request["route"].endpoint): 91 | self.thread_id = threading.get_ident() 92 | else: 93 | self.thread_id = await run_in_threadpool(threading.get_ident) 94 | 95 | collector.clear_collection(thread_id=self.thread_id) 96 | return await super().process_request(request) 97 | 98 | async def generate_stats(self, request: Request, response: Response) -> Stats: 99 | records = collector.get_collection(thread_id=self.thread_id) 100 | self._records[self.thread_id] = records 101 | collector.clear_collection(thread_id=self.thread_id) 102 | return {"records": records} 103 | -------------------------------------------------------------------------------- /debug_toolbar/panels/profiling.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from fastapi import Request, Response 4 | from pyinstrument import Profiler 5 | from starlette.concurrency import run_in_threadpool 6 | 7 | from debug_toolbar.panels import Panel 8 | from debug_toolbar.types import Stats 9 | from debug_toolbar.utils import is_coroutine 10 | 11 | 12 | class ProfilingPanel(Panel): 13 | title = "Profiling" 14 | template = "panels/profiling.html" 15 | 16 | async def process_request(self, request: Request) -> Response: 17 | self.profiler = Profiler(**self.toolbar.settings.PROFILER_OPTIONS) 18 | is_async = is_coroutine(request["route"].endpoint) 19 | 20 | async def call(f: t.Callable) -> None: 21 | await run_in_threadpool(f) if not is_async else f() 22 | 23 | await call(self.profiler.start) 24 | 25 | try: 26 | response = await super().process_request(request) 27 | finally: 28 | await call(self.profiler.stop) 29 | return response 30 | 31 | async def generate_stats(self, request: Request, response: Response) -> Stats: 32 | return {"content": self.profiler.output_html()} 33 | -------------------------------------------------------------------------------- /debug_toolbar/panels/redirects.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from fastapi import Request, Response, status 4 | 5 | from debug_toolbar.panels import Panel 6 | from debug_toolbar.responses import StreamingHTMLResponse 7 | 8 | 9 | class RedirectsPanel(Panel): 10 | has_content = False 11 | nav_title = "Intercept redirects" 12 | template = "redirect.html" 13 | 14 | async def process_request(self, request: Request) -> Response: 15 | response = await super().process_request(request) 16 | 17 | if 300 <= response.status_code < 400: 18 | if redirect_to := response.headers.get("Location"): 19 | 20 | async def content() -> t.AsyncGenerator[str, None]: 21 | yield self.render( 22 | redirect_to=redirect_to, 23 | status_code=response.status_code, 24 | ) 25 | 26 | response = StreamingHTMLResponse( 27 | content=content(), 28 | status_code=status.HTTP_200_OK, 29 | ) 30 | return response 31 | -------------------------------------------------------------------------------- /debug_toolbar/panels/request.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from fastapi import Request, Response 6 | 7 | from debug_toolbar.panels import Panel 8 | from debug_toolbar.types import Stats 9 | from debug_toolbar.utils import get_name_from_obj 10 | 11 | 12 | class RequestPanel(Panel): 13 | title = "Request" 14 | template = "panels/request.html" 15 | 16 | @property 17 | def nav_subtitle(self) -> str: 18 | return get_name_from_obj(self.endpoint) 19 | 20 | async def generate_stats(self, request: Request, response: Response) -> Stats: 21 | self.endpoint = request["endpoint"] 22 | stats: dict[str, t.Any] = {"request": request} 23 | 24 | if hasattr(self, "_form"): 25 | stats["form"] = await request.form() 26 | 27 | if "session" in request.scope: 28 | stats["session"] = request.session 29 | return stats 30 | -------------------------------------------------------------------------------- /debug_toolbar/panels/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Response 2 | 3 | from debug_toolbar.panels import Panel 4 | from debug_toolbar.types import Stats 5 | 6 | 7 | class RoutesPanel(Panel): 8 | title = "Routes" 9 | template = "panels/routes.html" 10 | 11 | async def generate_stats(self, request: Request, response: Response) -> Stats: 12 | return { 13 | "routes": request.app.routes, 14 | "endpoint": request["endpoint"], 15 | } 16 | -------------------------------------------------------------------------------- /debug_toolbar/panels/settings.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.panels import Panel 2 | 3 | 4 | class SettingsPanel(Panel): 5 | title = "Settings" 6 | template = "panels/settings.html" 7 | -------------------------------------------------------------------------------- /debug_toolbar/panels/sql.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import json 5 | import re 6 | import typing as t 7 | from collections import defaultdict 8 | 9 | import sqlparse 10 | from fastapi import Request, Response 11 | from fastapi.encoders import jsonable_encoder 12 | from pydantic_extra_types.color import Color 13 | from sqlparse import tokens as T 14 | 15 | from debug_toolbar.panels import Panel 16 | from debug_toolbar.types import ServerTiming, Stats 17 | from debug_toolbar.utils import color_generator 18 | 19 | __all__ = ["SQLPanel", "parse_sql", "raw_sql"] 20 | 21 | Stream = t.Generator[t.Tuple[T._TokenType, str], None, None] 22 | 23 | 24 | class BoldKeywordFilter: 25 | def process(self, stream: Stream) -> Stream: 26 | for token_type, value in stream: 27 | if is_keyword := token_type in T.Keyword: 28 | yield T.Text, "" 29 | yield token_type, html.escape(value) 30 | if is_keyword: 31 | yield T.Text, "" 32 | 33 | 34 | class RawFilter: 35 | def process(self, stream: Stream) -> Stream: 36 | for token_type, value in stream: 37 | if token_type not in T.Number and token_type != T.String.Single: 38 | yield token_type, value 39 | 40 | 41 | def simplify(sql: str) -> str: 42 | expr = r"SELECT (...........*?) FROM" 43 | return re.sub(expr, "SELECT ••• FROM", sql) 44 | 45 | 46 | class FilterStack(sqlparse.engine.FilterStack): 47 | def run(self, sql: str) -> str: 48 | self.postprocess.append(sqlparse.filters.SerializerUnicode()) 49 | return "".join(super().run(sql)) 50 | 51 | 52 | def parse_sql(sql: str, aligned_indent: bool = False) -> str: 53 | stack = FilterStack() 54 | 55 | if aligned_indent: 56 | stack.enable_grouping() 57 | stack.stmtprocess.append( 58 | sqlparse.filters.AlignedIndentFilter(char=" ", n="
") 59 | ) 60 | stack.preprocess.append(BoldKeywordFilter()) 61 | return stack.run(sql) 62 | 63 | 64 | def raw_sql(sql: str) -> str: 65 | stack = FilterStack() 66 | stack.preprocess.append(RawFilter()) 67 | return stack.run(sql) 68 | 69 | 70 | def nqueries(n: int) -> str: 71 | return f"{n} {'query' if n == 1 else 'queries'}" 72 | 73 | 74 | class SQLPanel(Panel): 75 | template = "panels/sql.html" 76 | 77 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: 78 | super().__init__(*args, **kwargs) 79 | self._sql_time: float = 0 80 | self._queries: list[tuple[str, dict[str, t.Any]]] = [] 81 | self._databases: dict[str, dict[str, t.Any]] = {} 82 | self._colors: t.Generator[Color, None, None] = color_generator() 83 | 84 | @property 85 | def nav_subtitle(self) -> str: 86 | return f"{nqueries(len(self._queries))} in {self._sql_time:.2f}ms" 87 | 88 | def add_query(self, alias: str, query: dict[str, t.Any]) -> None: 89 | duration = query["duration"] 90 | sql = query["sql"] 91 | 92 | query.update( 93 | { 94 | "sql_formatted": parse_sql(sql, aligned_indent=True), 95 | "sql_simple": simplify(parse_sql(sql, aligned_indent=False)), 96 | "is_slow": duration > self.toolbar.settings.SQL_WARNING_THRESHOLD, 97 | } 98 | ) 99 | if alias not in self._databases: 100 | self._databases[alias] = { 101 | "time_spent": duration, 102 | "num_queries": 1, 103 | "rgb_color": next(self._colors), 104 | } 105 | else: 106 | self._databases[alias]["time_spent"] += duration 107 | self._databases[alias]["num_queries"] += 1 108 | 109 | self._sql_time += duration 110 | self._queries.append((alias, jsonable_encoder(query))) 111 | 112 | async def generate_stats(self, request: Request, response: Response) -> Stats: 113 | trace_colors: dict[tuple[str, str], Color] = defaultdict( 114 | lambda: next(self._colors) 115 | ) 116 | duplicates: dict[str, dict[tuple[str, str], int]] = defaultdict( 117 | lambda: defaultdict(int) 118 | ) 119 | similar: dict[str, dict[str, t.Any]] = defaultdict(lambda: defaultdict(int)) 120 | width_ratio_tally = 0 121 | 122 | def dup_key(query: dict[str, t.Any]) -> tuple[str, str]: 123 | return (query["sql"], json.dumps(query["params"])) 124 | 125 | def sim_key(query: dict[str, t.Any]) -> str: 126 | return query.get("raw", query.get("sql")) 127 | 128 | for alias, query in self._queries: 129 | duplicates[alias][dup_key(query)] += 1 130 | similar[alias][sim_key(query)] += 1 131 | try: 132 | width_ratio = (query["duration"] / self._sql_time) * 100 133 | except ZeroDivisionError: 134 | width_ratio = 0 135 | 136 | query.update( 137 | { 138 | "trace_color": trace_colors[dup_key(query)], 139 | "start_offset": width_ratio_tally, 140 | "end_offset": width_ratio + width_ratio_tally, 141 | "width_ratio": width_ratio, 142 | } 143 | ) 144 | width_ratio_tally += width_ratio 145 | 146 | duplicates = { 147 | alias: {query: c for query, c in queries.items() if c > 1} 148 | for alias, queries in duplicates.items() 149 | } 150 | similar = { 151 | alias: { 152 | query: (c, next(self._colors)) for query, c in queries.items() if c > 1 153 | } 154 | for alias, queries in similar.items() 155 | } 156 | for alias, query in self._queries: 157 | try: 158 | query["sim_count"], query["sim_color"] = similar[alias][sim_key(query)] 159 | query["dup_count"] = duplicates[alias][dup_key(query)] 160 | except KeyError: 161 | continue 162 | 163 | for alias, info in self._databases.items(): 164 | try: 165 | info["sim_count"] = sum(c for c, _ in similar[alias].values()) 166 | info["dup_count"] = sum(c for c in duplicates[alias].values()) 167 | except KeyError: 168 | continue 169 | 170 | return { 171 | "databases": self._databases, 172 | "queries": self._queries, 173 | "sql_time": self._sql_time, 174 | } 175 | 176 | async def generate_server_timing( 177 | self, 178 | request: Request, 179 | response: Response, 180 | ) -> ServerTiming: 181 | stats = self.get_stats() 182 | n = len(stats.get("queries", [])) 183 | return [("sql", f"SQL {nqueries(n)}", stats.get("sql_time", 0))] 184 | -------------------------------------------------------------------------------- /debug_toolbar/panels/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from time import perf_counter 5 | 6 | from fastapi import Request, Response 7 | from sqlalchemy import event 8 | from sqlalchemy.engine import Connection, Engine, ExecutionContext 9 | from sqlalchemy.exc import UnboundExecutionError 10 | from sqlalchemy.ext.asyncio import AsyncSession 11 | from sqlalchemy.orm import Session 12 | 13 | from debug_toolbar.dependencies import get_dependencies 14 | from debug_toolbar.panels.sql import SQLPanel 15 | 16 | 17 | class SQLAlchemyPanel(SQLPanel): 18 | title = "SQLAlchemy" 19 | 20 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: 21 | super().__init__(*args, **kwargs) 22 | self.engines: t.Set[Engine] = set() 23 | 24 | def register(self, engine: Engine) -> None: 25 | event.listen(engine, "before_cursor_execute", self.before_execute, named=True) 26 | event.listen(engine, "after_cursor_execute", self.after_execute, named=True) 27 | 28 | def unregister(self, engine: Engine) -> None: 29 | event.remove(engine, "before_cursor_execute", self.before_execute) 30 | event.remove(engine, "after_cursor_execute", self.after_execute) 31 | 32 | def before_execute(self, context: ExecutionContext, **kwargs: t.Any) -> None: 33 | context._start_time = perf_counter() # type: ignore[attr-defined] 34 | 35 | def after_execute(self, context: ExecutionContext, **kwargs: t.Any) -> None: 36 | query = { 37 | "duration": ( 38 | perf_counter() - context._start_time # type: ignore[attr-defined] 39 | ) 40 | * 1000, 41 | "sql": context.statement, 42 | "params": context.parameters, 43 | } 44 | self.add_query(str(context.engine.url), query) 45 | 46 | def add_bind(self, bind: Connection | Engine): 47 | if isinstance(bind, Connection): 48 | self.engines.add(bind.engine) 49 | else: 50 | self.engines.add(bind) 51 | 52 | async def add_engines(self, request: Request): 53 | dependencies = await get_dependencies(request) 54 | 55 | if dependencies is not None: 56 | for value in dependencies.values(): 57 | if isinstance(value, AsyncSession): 58 | value = value.sync_session 59 | 60 | if isinstance(value, Session): 61 | try: 62 | bind = value.get_bind() 63 | except UnboundExecutionError: 64 | for bind in value._Session__binds.values(): # type: ignore[attr-defined] 65 | self.add_bind(bind) 66 | else: 67 | self.add_bind(bind) 68 | 69 | async def process_request(self, request: Request) -> Response: 70 | await self.add_engines(request) 71 | 72 | for engine in self.engines: 73 | self.register(engine) 74 | try: 75 | response = await super().process_request(request) 76 | finally: 77 | for engine in self.engines: 78 | self.unregister(engine) 79 | return response 80 | -------------------------------------------------------------------------------- /debug_toolbar/panels/timer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import perf_counter 4 | 5 | from fastapi import Request, Response 6 | 7 | from debug_toolbar.panels import Panel 8 | from debug_toolbar.types import ServerTiming, Stats 9 | 10 | try: 11 | import resource 12 | except ImportError: 13 | resource = None # type: ignore[assignment] 14 | 15 | 16 | class TimerPanel(Panel): 17 | has_content = resource is not None 18 | title = "Time" 19 | template = "panels/timer.html" 20 | 21 | @property 22 | def nav_subtitle(self) -> str: 23 | stats = self.get_stats() 24 | 25 | if hasattr(self, "_start_ru"): 26 | utime = self._end_ru.ru_utime - self._start_ru.ru_utime 27 | stime = self._end_ru.ru_stime - self._start_ru.ru_stime 28 | return f"CPU: {(utime + stime) * 1000.0:.2f}ms ({stats['elapsed']:.2f}ms)" 29 | 30 | return f"Total: {stats['elapsed']:.2f}ms" 31 | 32 | @property 33 | def content(self) -> str: 34 | stats = self.get_stats() 35 | 36 | rows = ( 37 | ("User CPU time", f"{stats['utime']:.3f} msec"), 38 | ("System CPU time", f"{stats['stime']:.3f} msec"), 39 | ("Total CPU time", f"{stats['total']:.3f} msec"), 40 | ("Elapsed time", f"{stats['elapsed']:.3f} msec"), 41 | ( 42 | "Context switches", 43 | f"{stats['vcsw']} voluntary, {stats['ivcsw']} involuntary", 44 | ), 45 | ) 46 | return self.render(rows=rows) 47 | 48 | @property 49 | def scripts(self) -> list[str]: 50 | scripts = super().scripts 51 | scripts.append(self.url_for("debug_toolbar.static", path="js/timer.js")) 52 | return scripts 53 | 54 | async def process_request(self, request: Request) -> Response: 55 | self._start_time = perf_counter() 56 | if self.has_content: 57 | self._start_ru = resource.getrusage(resource.RUSAGE_SELF) 58 | return await super().process_request(request) 59 | 60 | async def generate_stats(self, request: Request, response: Response) -> Stats: 61 | stats = { 62 | "elapsed": (perf_counter() - self._start_time) * 1000, 63 | } 64 | if hasattr(self, "_start_ru"): 65 | self._end_ru = resource.getrusage(resource.RUSAGE_SELF) 66 | utime = 1000 * self._elapsed_ru("ru_utime") 67 | stime = 1000 * self._elapsed_ru("ru_stime") 68 | 69 | stats.update( 70 | { 71 | "utime": utime, 72 | "stime": stime, 73 | "total": utime + stime, 74 | "vcsw": self._elapsed_ru("ru_nvcsw"), 75 | "ivcsw": self._elapsed_ru("ru_nivcsw"), 76 | } 77 | ) 78 | return stats 79 | 80 | async def generate_server_timing( 81 | self, 82 | request: Request, 83 | response: Response, 84 | ) -> ServerTiming: 85 | stats = self.get_stats() 86 | 87 | return [ 88 | ("utime", "User CPU time", stats.get("utime", 0)), 89 | ("stime", "System CPU time", stats.get("stime", 0)), 90 | ("total", "Total CPU time", stats.get("total", 0)), 91 | ("elapsed", "Elapsed time", stats.get("elapsed", 0)), 92 | ] 93 | 94 | def _elapsed_ru(self, name: str) -> float: 95 | return getattr(self._end_ru, name) - getattr(self._start_ru, name) 96 | -------------------------------------------------------------------------------- /debug_toolbar/panels/tortoise.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from contextlib import contextmanager 5 | from time import perf_counter 6 | 7 | from fastapi import Request, Response 8 | from tortoise.backends.base.client import BaseDBAsyncClient 9 | 10 | try: 11 | from tortoise.connection import connections 12 | except ImportError: 13 | connections = None # type: ignore 14 | 15 | from debug_toolbar.panels.sql import SQLPanel, raw_sql 16 | 17 | 18 | class DBWrapper: 19 | def __init__(self, db: BaseDBAsyncClient, on_execute: t.Callable[..., t.Any]): 20 | self.db = db 21 | self.on_execute = on_execute 22 | 23 | def __getattr__(self, attr: str) -> t.Any: 24 | return getattr(self.db, attr) 25 | 26 | async def execute_insert(self, query: str, values: list) -> t.Any: 27 | with self.on_execute(self.db, query, values): 28 | return await self.db.execute_insert(query, values) 29 | 30 | async def execute_query( 31 | self, 32 | query: str, 33 | values: list | None = None, 34 | ) -> tuple[int, t.Sequence[dict]]: 35 | with self.on_execute(self.db, query, values): 36 | return await self.db.execute_query(query, values) 37 | 38 | async def execute_script(self, query: str) -> None: 39 | with self.on_execute(self.db, query): 40 | await self.db.execute_script(query) 41 | 42 | async def execute_many(self, query: str, values: list[list]) -> None: 43 | with self.on_execute(self.db, query, values): 44 | await self.db.execute_many(query, values) 45 | 46 | async def execute_query_dict( 47 | self, 48 | query: str, 49 | values: list | None = None, 50 | ) -> list[dict]: 51 | with self.on_execute(self.db, query, values): 52 | return await self.db.execute_query_dict(query, values) 53 | 54 | 55 | class TortoisePanel(SQLPanel): 56 | title = "Tortoise ORM" 57 | 58 | @contextmanager 59 | def on_execute( 60 | self, 61 | db: BaseDBAsyncClient, 62 | statement: str, 63 | values: t.Any = None, 64 | ) -> t.Iterator[None]: 65 | start_time = perf_counter() 66 | try: 67 | yield 68 | finally: 69 | query = { 70 | "duration": (perf_counter() - start_time) * 1000, 71 | "sql": statement, 72 | "raw": raw_sql(statement), 73 | "params": values, 74 | "is_select": statement.lower().strip().startswith("select"), 75 | } 76 | self.add_query(db.connection_name, query) 77 | 78 | async def process_request(self, request: Request) -> Response: 79 | assert connections is not None, "tortoise-orm >= 0.19.0 is required" 80 | 81 | for conn in connections.all(): 82 | db = DBWrapper(conn, self.on_execute) 83 | connections.set(conn.connection_name, db) # type: ignore[arg-type] 84 | try: 85 | response = await super().process_request(request) 86 | finally: 87 | for conn in connections.all(): 88 | connections.set(conn.connection_name, conn.db) # type: ignore 89 | return response 90 | -------------------------------------------------------------------------------- /debug_toolbar/panels/versions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib import metadata 4 | 5 | from fastapi import Request, Response, __version__ 6 | 7 | from debug_toolbar.panels import Panel 8 | from debug_toolbar.types import Stats 9 | 10 | 11 | class VersionsPanel(Panel): 12 | title = "Versions" 13 | template = "panels/versions.html" 14 | 15 | @property 16 | def nav_subtitle(self) -> str: 17 | return f"FastAPI {__version__}" 18 | 19 | @property 20 | def scripts(self) -> list[str]: 21 | scripts = super().scripts 22 | scripts.append(self.url_for("debug_toolbar.static", path="js/versions.js")) 23 | return scripts 24 | 25 | async def generate_stats(self, request: Request, response: Response) -> Stats: 26 | packages = sorted( 27 | metadata.distributions(), 28 | key=lambda dist: dist.metadata["name"].lower(), 29 | ) 30 | return {"packages": packages} 31 | -------------------------------------------------------------------------------- /debug_toolbar/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/debug_toolbar/py.typed -------------------------------------------------------------------------------- /debug_toolbar/responses.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import StreamingResponse 2 | 3 | 4 | class StreamingHTMLResponse(StreamingResponse): 5 | media_type = "text/html" 6 | -------------------------------------------------------------------------------- /debug_toolbar/settings.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from jinja2 import BaseLoader, ChoiceLoader, Environment, PackageLoader 4 | from jinja2.ext import Extension 5 | from pydantic import Field, model_validator 6 | from pydantic_extra_types.color import Color 7 | from pydantic_settings import BaseSettings, SettingsConfigDict 8 | 9 | 10 | class DebugToolbarSettings(BaseSettings): 11 | model_config = SettingsConfigDict( 12 | title="Debug Toolbar", 13 | env_prefix="DT_", 14 | case_sensitive=False, 15 | ) 16 | 17 | DEFAULT_PANELS: t.List[str] = Field( 18 | [ 19 | "debug_toolbar.panels.versions.VersionsPanel", 20 | "debug_toolbar.panels.timer.TimerPanel", 21 | "debug_toolbar.panels.settings.SettingsPanel", 22 | "debug_toolbar.panels.request.RequestPanel", 23 | "debug_toolbar.panels.headers.HeadersPanel", 24 | "debug_toolbar.panels.routes.RoutesPanel", 25 | "debug_toolbar.panels.logging.LoggingPanel", 26 | "debug_toolbar.panels.profiling.ProfilingPanel", 27 | "debug_toolbar.panels.redirects.RedirectsPanel", 28 | ], 29 | description=( 30 | "Specifies the full Python path to each panel that you " 31 | "want included in the toolbar." 32 | ), 33 | ) 34 | PANELS: t.List[str] = Field( 35 | [], 36 | description=( 37 | "A list of the full Python paths to each panel that you " 38 | "want to append to `DEFAULT_PANELS`." 39 | ), 40 | ) 41 | DISABLE_PANELS: t.Sequence[str] = Field( 42 | ["debug_toolbar.panels.redirects.RedirectsPanel"], 43 | description=( 44 | "A list of the full Python paths to each panel that you " 45 | "want disabled (but still displayed) by default." 46 | ), 47 | ) 48 | ALLOWED_HOSTS: t.Optional[t.Sequence[str]] = Field( 49 | None, 50 | description=( 51 | "If it's set, the Debug Toolbar is shown only " 52 | "if the request host is listed." 53 | ), 54 | ) 55 | JINJA_ENV: Environment = Field( 56 | Environment(autoescape=True), 57 | description="The Jinja environment instance used to render the toolbar.", 58 | ) 59 | JINJA_LOADERS: t.List[BaseLoader] = Field( 60 | [], 61 | description=( 62 | "Jinja `BaseLoader` subclasses used to load templates " 63 | "from the file system or other locations." 64 | ), 65 | ) 66 | JINJA_EXTENSIONS: t.Sequence[t.Union[str, t.Type[Extension]]] = Field( 67 | [], 68 | description=( 69 | "Load the extensions from the list and bind them to the Jinja environment." 70 | ), 71 | ) 72 | API_URL: str = Field( 73 | "/_debug_toolbar", 74 | description="URL prefix to use for toolbar endpoints.", 75 | ) 76 | STATIC_URL: str = Field( 77 | f"{API_URL.default}/static", # type: ignore[attr-defined] 78 | description="URL to use when referring to toolbar static files.", 79 | ) 80 | SHOW_TOOLBAR_CALLBACK: str = Field( 81 | "debug_toolbar.middleware.show_toolbar", 82 | description=( 83 | "This is the dotted path to a function used for " 84 | "determining whether the toolbar should show or not." 85 | ), 86 | ) 87 | INSERT_BEFORE: str = Field( 88 | "", 89 | description=( 90 | "The toolbar searches for this string in the HTML " 91 | "and inserts itself just before." 92 | ), 93 | ) 94 | SHOW_COLLAPSE: bool = Field( 95 | False, 96 | description="If changed to `True`, the toolbar will be collapsed by default.", 97 | ) 98 | ROOT_TAG_EXTRA_ATTRS: str = Field( 99 | "", 100 | description=( 101 | "This setting is injected in the root template div " 102 | "in order to avoid conflicts with client-side frameworks" 103 | ), 104 | ) 105 | RESULTS_CACHE_SIZE: int = Field( 106 | 25, 107 | description="The toolbar keeps up to this many results in memory.", 108 | ) 109 | PROFILER_OPTIONS: t.Dict[str, t.Any] = Field( 110 | {"interval": 0.0001}, 111 | description="A list of arguments can be supplied to the Profiler.", 112 | ) 113 | SETTINGS: t.Sequence[BaseSettings] = Field( 114 | [], 115 | description=( 116 | "pydantic's `BaseSettings` instances to be " 117 | "displayed on the `SettingsPanel`." 118 | ), 119 | ) 120 | LOGGING_COLORS: t.Dict[str, Color] = Field( 121 | { 122 | "CRITICAL": Color("rgba(255, 0, 0, .4)"), 123 | "ERROR": Color("rgba(255, 0, 0, .2)"), 124 | "WARNING": Color("rgba(255, 165, 0, .2)"), 125 | "INFO": Color("rgba(135, 206, 235, .2)"), 126 | "DEBUG": Color("rgba(128, 128, 128, .2)"), 127 | }, 128 | description="Color palette used to apply colors based on the log level.", 129 | ) 130 | SQL_WARNING_THRESHOLD: int = Field( 131 | 500, 132 | description=( 133 | "The SQL panel highlights queries that took more that this amount of " 134 | "time, in milliseconds, to execute." 135 | ), 136 | ) 137 | 138 | def __init__(self, **settings: t.Any) -> None: 139 | super().__init__(**settings) 140 | loaders = self.JINJA_LOADERS + [PackageLoader("debug_toolbar", "templates")] 141 | self.JINJA_ENV.loader = ChoiceLoader(loaders) 142 | self.JINJA_ENV.trim_blocks = True 143 | self.JINJA_ENV.lstrip_blocks = True 144 | 145 | for extension in self.JINJA_EXTENSIONS: 146 | self.JINJA_ENV.add_extension(extension) 147 | 148 | @model_validator(mode="before") 149 | def ci(cls, data: dict): 150 | return {k.upper(): v for k, v in data.items()} 151 | -------------------------------------------------------------------------------- /debug_toolbar/statics/css/print.css: -------------------------------------------------------------------------------- 1 | #fastDebug { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /debug_toolbar/statics/css/toolbar.css: -------------------------------------------------------------------------------- 1 | /* Debug Toolbar CSS Reset, adapted from Eric Meyer's CSS Reset */ 2 | #fastDebug { 3 | color: #000; 4 | background: #fff; 5 | } 6 | #fastDebug, 7 | #fastDebug div, 8 | #fastDebug span, 9 | #fastDebug applet, 10 | #fastDebug object, 11 | #fastDebug iframe, 12 | #fastDebug h1, 13 | #fastDebug h2, 14 | #fastDebug h3, 15 | #fastDebug h4, 16 | #fastDebug h5, 17 | #fastDebug h6, 18 | #fastDebug p, 19 | #fastDebug blockquote, 20 | #fastDebug pre, 21 | #fastDebug a, 22 | #fastDebug abbr, 23 | #fastDebug acronym, 24 | #fastDebug address, 25 | #fastDebug big, 26 | #fastDebug cite, 27 | #fastDebug code, 28 | #fastDebug del, 29 | #fastDebug dfn, 30 | #fastDebug em, 31 | #fastDebug font, 32 | #fastDebug img, 33 | #fastDebug ins, 34 | #fastDebug kbd, 35 | #fastDebug q, 36 | #fastDebug s, 37 | #fastDebug samp, 38 | #fastDebug small, 39 | #fastDebug strike, 40 | #fastDebug strong, 41 | #fastDebug sub, 42 | #fastDebug sup, 43 | #fastDebug tt, 44 | #fastDebug var, 45 | #fastDebug b, 46 | #fastDebug u, 47 | #fastDebug i, 48 | #fastDebug center, 49 | #fastDebug dl, 50 | #fastDebug dt, 51 | #fastDebug dd, 52 | #fastDebug ol, 53 | #fastDebug ul, 54 | #fastDebug li, 55 | #fastDebug fieldset, 56 | #fastDebug form, 57 | #fastDebug label, 58 | #fastDebug legend, 59 | #fastDebug table, 60 | #fastDebug caption, 61 | #fastDebug tbody, 62 | #fastDebug tfoot, 63 | #fastDebug thead, 64 | #fastDebug tr, 65 | #fastDebug th, 66 | #fastDebug td, 67 | #fastDebug button { 68 | margin: 0; 69 | padding: 0; 70 | min-width: 0; 71 | width: auto; 72 | border: 0; 73 | outline: 0; 74 | font-size: 12px; 75 | line-height: 1.5em; 76 | color: #000; 77 | vertical-align: baseline; 78 | background-color: transparent; 79 | font-family: sans-serif; 80 | text-align: left; 81 | text-shadow: none; 82 | white-space: normal; 83 | transition: none; 84 | } 85 | 86 | #fastDebug button { 87 | background-color: #eee; 88 | background-image: linear-gradient(to bottom, #eee, #cccccc); 89 | border: 1px solid #ccc; 90 | border-bottom: 1px solid #bbb; 91 | border-radius: 3px; 92 | color: #333; 93 | line-height: 1; 94 | padding: 0 8px; 95 | text-align: center; 96 | text-shadow: 0 1px 0 #eee; 97 | } 98 | 99 | #fastDebug button:hover { 100 | background-color: #ddd; 101 | background-image: linear-gradient(to bottom, #ddd, #bbb); 102 | border-color: #bbb; 103 | border-bottom-color: #999; 104 | cursor: pointer; 105 | text-shadow: 0 1px 0 #ddd; 106 | } 107 | 108 | #fastDebug button:active { 109 | border: 1px solid #aaa; 110 | border-bottom: 1px solid #888; 111 | box-shadow: 112 | inset 0 0 5px 2px #aaa, 113 | 0 1px 0 0 #eee; 114 | } 115 | 116 | #fastDebug #fastDebugToolbar { 117 | background-color: #111; 118 | width: 220px; 119 | z-index: 100000000; 120 | position: fixed; 121 | top: 0; 122 | bottom: 0; 123 | right: 0; 124 | opacity: 0.9; 125 | overflow-y: auto; 126 | } 127 | 128 | #fastDebug #fastDebugToolbar small { 129 | color: #999; 130 | } 131 | 132 | #fastDebug #fastDebugToolbar ul { 133 | margin: 0; 134 | padding: 0; 135 | list-style: none; 136 | } 137 | 138 | #fastDebug #fastDebugToolbar li { 139 | border-bottom: 1px solid #222; 140 | color: #fff; 141 | display: block; 142 | font-weight: bold; 143 | float: none; 144 | margin: 0; 145 | padding: 0; 146 | position: relative; 147 | width: auto; 148 | } 149 | 150 | #fastDebug #fastDebugToolbar input[type="checkbox"] { 151 | float: right; 152 | margin: 10px; 153 | } 154 | 155 | #fastDebug #fastDebugToolbar li > a, 156 | #fastDebug #fastDebugToolbar li > div.fastdt-contentless { 157 | font-weight: normal; 158 | font-style: normal; 159 | text-decoration: none; 160 | display: block; 161 | font-size: 16px; 162 | padding: 10px 10px 5px 25px; 163 | color: #fff; 164 | } 165 | #fastDebug #fastDebugToolbar li > div.fastdt-disabled { 166 | font-style: italic; 167 | color: #999; 168 | } 169 | 170 | #fastDebug #fastDebugToolbar li a:hover { 171 | color: #111; 172 | background-color: #ffc; 173 | } 174 | 175 | #fastDebug #fastDebugToolbar li.fastdt-active { 176 | background: #333; 177 | } 178 | 179 | #fastDebug #fastDebugToolbar li.fastdt-active:before { 180 | content: "▶"; 181 | position: absolute; 182 | left: 0; 183 | top: 50%; 184 | transform: translateY(-50%); 185 | color: #eee; 186 | font-size: 150%; 187 | } 188 | 189 | #fastDebug #fastDebugToolbar li.fastdt-active a:hover { 190 | color: #b36a60; 191 | background-color: transparent; 192 | } 193 | 194 | #fastDebug #fastDebugToolbar li small { 195 | font-size: 12px; 196 | color: #999; 197 | font-style: normal; 198 | text-decoration: none; 199 | } 200 | 201 | #fastDebug #fastDebugToolbarHandle { 202 | position: fixed; 203 | transform: translateY(-100%) rotate(-90deg); 204 | transform-origin: right bottom; 205 | background-color: #fff; 206 | border: 1px solid #111; 207 | border-bottom: 0; 208 | top: 0; 209 | right: 0; 210 | z-index: 100000000; 211 | opacity: 0.75; 212 | } 213 | 214 | #fastDebug #fastShowToolBarButton { 215 | padding: 0 5px; 216 | border: 4px solid #fff; 217 | border-bottom-width: 0; 218 | color: #fff; 219 | font-size: 22px; 220 | font-weight: bold; 221 | background: #000; 222 | opacity: 0.5; 223 | } 224 | 225 | #fastDebug #fastShowToolBarButton:hover { 226 | background-color: #111; 227 | border-color: #ffe761; 228 | cursor: move; 229 | opacity: 1; 230 | } 231 | 232 | #fastDebug #fastShowToolBarLogo img { 233 | height: 1.8em; 234 | vertical-align: text-bottom; 235 | } 236 | 237 | #fastDebug code { 238 | display: block; 239 | font-family: Consolas, Monaco, "Bitstream Vera Sans Mono", "Lucida Console", monospace; 240 | font-size: 12px; 241 | white-space: pre; 242 | overflow: auto; 243 | } 244 | 245 | #fastDebug .fastdt-panelContent { 246 | position: fixed; 247 | margin: 0; 248 | top: 0; 249 | right: 220px; 250 | bottom: 0; 251 | left: 0px; 252 | background-color: #eee; 253 | color: #666; 254 | z-index: 100000000; 255 | } 256 | 257 | #fastDebug .fastdt-panelContent > div { 258 | border-bottom: 1px solid #ddd; 259 | } 260 | 261 | #fastDebug .fastDebugPanelTitle { 262 | position: absolute; 263 | background-color: #ffc; 264 | color: #666; 265 | padding-left: 20px; 266 | top: 0; 267 | right: 0; 268 | left: 0; 269 | height: 50px; 270 | } 271 | 272 | #fastDebug .fastDebugPanelTitle code { 273 | display: inline; 274 | font-size: inherit; 275 | } 276 | 277 | #fastDebug .fastDebugPanelContent { 278 | position: absolute; 279 | top: 50px; 280 | right: 0; 281 | bottom: 0; 282 | left: 0; 283 | height: auto; 284 | padding: 5px 0 0 20px; 285 | } 286 | 287 | #fastDebug .fastDebugPanelContent .fastdt-loader { 288 | margin: 80px auto; 289 | border: 6px solid white; 290 | border-radius: 50%; 291 | border-top: 6px solid #ffe761; 292 | width: 38px; 293 | height: 38px; 294 | animation: spin 2s linear infinite; 295 | } 296 | 297 | @keyframes spin { 298 | 0% { 299 | transform: rotate(0deg); 300 | } 301 | 100% { 302 | transform: rotate(360deg); 303 | } 304 | } 305 | 306 | #fastDebug .fastDebugPanelContent .fastdt-scroll { 307 | height: 100%; 308 | overflow: auto; 309 | display: block; 310 | padding: 0 10px 0 0; 311 | } 312 | 313 | #fastDebug h3 { 314 | font-size: 24px; 315 | font-weight: normal; 316 | line-height: 50px; 317 | } 318 | 319 | #fastDebug h4 { 320 | font-size: 20px; 321 | font-weight: bold; 322 | margin-top: 0.8em; 323 | } 324 | 325 | #fastDebug .fastdt-panelContent table { 326 | border: 1px solid #ccc; 327 | border-collapse: collapse; 328 | width: 100%; 329 | background-color: #fff; 330 | display: table; 331 | margin-top: 0.8em; 332 | overflow: auto; 333 | } 334 | #fastDebug .fastdt-panelContent tbody > tr:nth-child(odd) { 335 | background-color: #f5f5f5; 336 | } 337 | #fastDebug .fastdt-panelContent tbody td, 338 | #fastDebug .fastdt-panelContent tbody th { 339 | vertical-align: top; 340 | padding: 2px 3px; 341 | } 342 | #fastDebug .fastdt-panelContent tbody td.fastdt-time { 343 | text-align: center; 344 | } 345 | 346 | #fastDebug .fastdt-panelContent thead th { 347 | padding: 1px 6px 1px 3px; 348 | text-align: left; 349 | font-weight: bold; 350 | font-size: 14px; 351 | white-space: nowrap; 352 | } 353 | #fastDebug .fastdt-panelContent tbody th { 354 | width: 12em; 355 | text-align: right; 356 | color: #666; 357 | padding-right: 0.5em; 358 | } 359 | 360 | #fastDebug .fastTemplateContext { 361 | background-color: #fff; 362 | } 363 | 364 | #fastDebug .fastdt-panelContent .fastDebugClose { 365 | position: absolute; 366 | top: 4px; 367 | right: 15px; 368 | height: 16px; 369 | width: 16px; 370 | line-height: 16px; 371 | padding: 5px; 372 | border: 6px solid #ddd; 373 | border-radius: 50%; 374 | background: #fff; 375 | color: #ddd; 376 | text-align: center; 377 | font-weight: 900; 378 | font-size: 20px; 379 | box-sizing: content-box; 380 | } 381 | 382 | #fastDebug .fastdt-panelContent .fastDebugClose:hover { 383 | background: #c0695d; 384 | } 385 | 386 | #fastDebug .fastdt-panelContent dt, 387 | #fastDebug .fastdt-panelContent dd { 388 | display: block; 389 | } 390 | 391 | #fastDebug .fastdt-panelContent dt { 392 | margin-top: 0.75em; 393 | } 394 | 395 | #fastDebug .fastdt-panelContent dd { 396 | margin-left: 10px; 397 | } 398 | 399 | #fastDebug a.toggleTemplate { 400 | padding: 4px; 401 | background-color: #bbb; 402 | border-radius: 3px; 403 | } 404 | 405 | #fastDebug a.toggleTemplate:hover { 406 | padding: 4px; 407 | background-color: #444; 408 | color: #ffe761; 409 | border-radius: 3px; 410 | } 411 | 412 | #fastDebug .fastDebugCollapsed { 413 | color: #333; 414 | } 415 | 416 | #fastDebug .fastDebugUncollapsed { 417 | color: #333; 418 | } 419 | 420 | #fastDebug .fastUnselected { 421 | display: none; 422 | } 423 | 424 | #fastDebug tr.fastSelected { 425 | display: table-row; 426 | } 427 | 428 | #fastDebug .fastDebugSql { 429 | overflow-wrap: anywhere; 430 | } 431 | 432 | #fastDebug .fastSQLDetailsDiv tbody th { 433 | text-align: left; 434 | } 435 | 436 | #fastDebug span.fastDebugLineChart { 437 | background-color: #777; 438 | height: 3px; 439 | position: absolute; 440 | bottom: 0; 441 | top: 0; 442 | left: 0; 443 | display: block; 444 | z-index: 1000000001; 445 | } 446 | #fastDebug span.fastDebugLineChartWarning { 447 | background-color: #900; 448 | } 449 | 450 | #fastDebug .highlight { 451 | color: #000; 452 | } 453 | #fastDebug .highlight .err { 454 | color: #000; 455 | } /* Error */ 456 | #fastDebug .highlight .g { 457 | color: #000; 458 | } /* Generic */ 459 | #fastDebug .highlight .k { 460 | color: #000; 461 | font-weight: bold; 462 | } /* Keyword */ 463 | #fastDebug .highlight .o { 464 | color: #000; 465 | } /* Operator */ 466 | #fastDebug .highlight .n { 467 | color: #000; 468 | } /* Name */ 469 | #fastDebug .highlight .mi { 470 | color: #000; 471 | font-weight: bold; 472 | } /* Literal.Number.Integer */ 473 | #fastDebug .highlight .l { 474 | color: #000; 475 | } /* Literal */ 476 | #fastDebug .highlight .x { 477 | color: #000; 478 | } /* Other */ 479 | #fastDebug .highlight .p { 480 | color: #000; 481 | } /* Punctuation */ 482 | #fastDebug .highlight .m { 483 | color: #000; 484 | font-weight: bold; 485 | } /* Literal.Number */ 486 | #fastDebug .highlight .s { 487 | color: #333; 488 | } /* Literal.String */ 489 | #fastDebug .highlight .w { 490 | color: #888888; 491 | } /* Text.Whitespace */ 492 | #fastDebug .highlight .il { 493 | color: #000; 494 | font-weight: bold; 495 | } /* Literal.Number.Integer.Long */ 496 | #fastDebug .highlight .na { 497 | color: #333; 498 | } /* Name.Attribute */ 499 | #fastDebug .highlight .nt { 500 | color: #000; 501 | font-weight: bold; 502 | } /* Name.Tag */ 503 | #fastDebug .highlight .nv { 504 | color: #333; 505 | } /* Name.Variable */ 506 | #fastDebug .highlight .s2 { 507 | color: #333; 508 | } /* Literal.String.Double */ 509 | #fastDebug .highlight .cp { 510 | color: #333; 511 | } /* Comment.Preproc */ 512 | 513 | #fastDebug svg.fastDebugLineChart { 514 | width: 100%; 515 | height: 1.5em; 516 | } 517 | 518 | #fastDebug svg.fastDebugLineChartWarning rect { 519 | fill: #900; 520 | } 521 | 522 | #fastDebug svg.fastDebugLineChart line { 523 | stroke: #94b24d; 524 | } 525 | 526 | #fastDebug .fastDebugRowWarning .fastdt-time { 527 | color: red; 528 | } 529 | #fastDebug .fastdt-panelContent table .fastdt-toggle { 530 | width: 14px; 531 | padding-top: 3px; 532 | } 533 | #fastDebug .fastdt-panelContent table .fastdt-actions { 534 | min-width: 70px; 535 | white-space: nowrap; 536 | } 537 | #fastDebug .fastdt-color:after { 538 | content: "\00a0"; 539 | } 540 | #fastDebug .fastToggleSwitch { 541 | box-sizing: content-box; 542 | padding: 0; 543 | border: 1px solid #999; 544 | border-radius: 0; 545 | width: 12px; 546 | color: #777; 547 | background: linear-gradient(to bottom, #fff, #dcdcdc); 548 | } 549 | #fastDebug .fastNoToggleSwitch { 550 | height: 14px; 551 | width: 14px; 552 | display: inline-block; 553 | } 554 | 555 | #fastDebug .fastSQLDetailsDiv { 556 | margin-top: 0.8em; 557 | } 558 | #fastDebug pre { 559 | white-space: pre-wrap; 560 | color: #555; 561 | border: 1px solid #ccc; 562 | border-collapse: collapse; 563 | background-color: #fff; 564 | display: block; 565 | overflow: auto; 566 | padding: 2px 3px; 567 | margin-bottom: 3px; 568 | font-family: Consolas, Monaco, "Bitstream Vera Sans Mono", "Lucida Console", monospace; 569 | } 570 | #fastDebug .fastdt-stack span { 571 | color: #000; 572 | font-weight: bold; 573 | } 574 | #fastDebug .fastdt-stack span.fastdt-type, 575 | #fastDebug .fastdt-stack pre.fastdt-locals, 576 | #fastDebug .fastdt-stack pre.fastdt-locals span { 577 | color: #777; 578 | font-weight: normal; 579 | } 580 | #fastDebug .fastdt-stack span.fastdt-code { 581 | font-weight: normal; 582 | } 583 | #fastDebug .fastdt-stack pre.fastdt-locals { 584 | margin: 0 27px 27px 27px; 585 | } 586 | 587 | #fastDebug .fastdt-width-20 { 588 | width: 20%; 589 | } 590 | #fastDebug .fastdt-width-30 { 591 | width: 30%; 592 | } 593 | #fastDebug .fastdt-width-60 { 594 | width: 60%; 595 | } 596 | #fastDebug .fastdt-max-height-100 { 597 | max-height: 100%; 598 | } 599 | #fastDebug .fastdt-highlighted { 600 | background-color: lightgrey; 601 | } 602 | .fastdt-hidden { 603 | display: none; 604 | } 605 | 606 | #ProfilingPanel .fastDebugPanelContent, 607 | #ProfilingPanel .fastDebugPanelContent .fastdt-scroll { 608 | padding: 0; 609 | } 610 | iframe#profilingContent { 611 | width: 100%; 612 | height: 100%; 613 | } 614 | 615 | #VersionsPanel tbody td code { 616 | display: inline; 617 | } 618 | #VersionsPanel tbody td span.fast-date { 619 | font-size: 10px; 620 | } 621 | #VersionsPanel .fastdt-hidden { 622 | display: none !important; 623 | } 624 | 625 | #fastDebug .fastSQLDetailsDiv code { 626 | display: inline; 627 | font-size: 11px; 628 | } 629 | 630 | #HeadersPanel table { 631 | table-layout: fixed; 632 | } 633 | #HeadersPanel tbody td { 634 | overflow-wrap: break-word; 635 | } 636 | 637 | #RequestPanel tbody td:last-child code { 638 | word-break: break-word; 639 | white-space: normal; 640 | } 641 | 642 | #RoutesPanel tbody td code, 643 | #LoggingPanel tbody td code { 644 | word-break: break-word; 645 | white-space: pre-wrap; 646 | } 647 | -------------------------------------------------------------------------------- /debug_toolbar/statics/img/icon-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /debug_toolbar/statics/img/icon-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/redirect.js: -------------------------------------------------------------------------------- 1 | document.getElementById("redirect_to").focus(); 2 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/refresh.js: -------------------------------------------------------------------------------- 1 | const refresh = (function () { 2 | function getCookie(name) { 3 | const parts = `; ${document.cookie}`.split(`; ${name}=`); 4 | if (parts.length === 2) return parts.pop().split(";").shift(); 5 | } 6 | let lastCookie = getCookie("dtRefresh"); 7 | const fastDebug = document.getElementById("fastDebug"); 8 | 9 | return function () { 10 | const dtCookie = getCookie("dtRefresh"); 11 | 12 | if (dtCookie && dtCookie !== lastCookie) { 13 | lastCookie = dtCookie; 14 | const toolbar = JSON.parse(decodeURIComponent(lastCookie)); 15 | 16 | Object.entries(toolbar.panels).map(([id, subtitle]) => { 17 | document.getElementById(`fastdt-${id}`).querySelector("small").textContent = subtitle; 18 | }); 19 | fastDebug.querySelectorAll(".fastDebugPanelContent").forEach(function (e) { 20 | e.querySelector(".fastdt-scroll").innerHTML = ""; 21 | 22 | if (e.querySelector(".fastdt-loader") === null) { 23 | const loader = document.createElement("div"); 24 | loader.className = "fastdt-loader"; 25 | e.prepend(loader); 26 | } 27 | }); 28 | fastDebug.setAttribute("data-store-id", toolbar.storeId); 29 | } 30 | }; 31 | })(); 32 | 33 | window.setInterval(refresh, 100); 34 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/timer.js: -------------------------------------------------------------------------------- 1 | import { $$ } from "./utils.js"; 2 | 3 | function insertBrowserTiming() { 4 | const timing = performance.timing, 5 | timingOffset = timing.navigationStart, 6 | totalTime = timing.loadEventEnd - timingOffset; 7 | 8 | function getLeft(stat) { 9 | return ((timing[stat] - timingOffset) / totalTime) * 100.0; 10 | } 11 | function getCSSWidth(stat, endStat) { 12 | let width = ((timing[endStat] - timing[stat]) / totalTime) * 100.0; 13 | // Calculate relative percent 14 | width = (100.0 * width) / (100.0 - getLeft(stat)); 15 | return width < 1 ? "2px" : `${width || 0}%`; 16 | } 17 | function addRow(tbody, stat, endStat) { 18 | const row = document.createElement("tr"); 19 | if (endStat) { 20 | // Render a start through end bar 21 | row.innerHTML = ` 22 | ${stat.replace("Start", "")} 23 | 24 | 28 | 29 | 30 | 31 | 32 | ${timing[stat] - timingOffset} 33 | (${timing[endStat] - timing[stat]}) 34 | `; 35 | row.querySelector("rect").setAttribute("width", getCSSWidth(stat, endStat)); 36 | } else { 37 | // Render a point in time 38 | row.innerHTML = ` 39 | ${stat} 40 | 41 | 45 | 46 | 47 | 48 | ${timing[stat] - timingOffset}`; 49 | row.querySelector("rect").setAttribute("width", 2); 50 | } 51 | row.querySelector("rect").setAttribute("x", getLeft(stat)); 52 | tbody.appendChild(row); 53 | } 54 | 55 | const browserTiming = document.getElementById("fastDebugBrowserTiming"); 56 | // Determine if the browser timing section has already been rendered. 57 | if (browserTiming.classList.contains("fastdt-hidden")) { 58 | const tbody = document.getElementById("fastDebugBrowserTimingTableBody"); 59 | // This is a reasonably complete and ordered set of 60 | // timing periods (2 params) and events (1 param) 61 | addRow(tbody, "domainLookupStart", "domainLookupEnd"); 62 | addRow(tbody, "connectStart", "connectEnd"); 63 | addRow(tbody, "requestStart", "responseEnd"); // There is no requestEnd 64 | addRow(tbody, "responseStart", "responseEnd"); 65 | addRow(tbody, "domLoading", "domComplete"); // Spans the events below 66 | addRow(tbody, "domInteractive"); 67 | addRow(tbody, "domContentLoadedEventStart", "domContentLoadedEventEnd"); 68 | addRow(tbody, "loadEventStart", "loadEventEnd"); 69 | browserTiming.classList.remove("fastdt-hidden"); 70 | } 71 | } 72 | 73 | const fastDebug = document.getElementById("fastDebug"); 74 | // Insert the browser timing now since it's possible for this 75 | // script to miss the initial panel load event. 76 | insertBrowserTiming(); 77 | $$.onPanelRender(fastDebug, "TimerPanel", insertBrowserTiming); 78 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/toolbar.js: -------------------------------------------------------------------------------- 1 | import { $$, ajax } from "./utils.js"; 2 | 3 | function onKeyDown(event) { 4 | if (event.keyCode === 27) { 5 | fastdt.hide_one_level(); 6 | } 7 | } 8 | 9 | const fastdt = { 10 | handleDragged: false, 11 | init() { 12 | const fastDebug = document.getElementById("fastDebug"); 13 | $$.show(fastDebug); 14 | $$.on(document.getElementById("fastDebugPanelList"), "click", "li a", function (event) { 15 | event.preventDefault(); 16 | if (!this.className) { 17 | return; 18 | } 19 | const panelId = this.className; 20 | const current = document.getElementById(panelId); 21 | if ($$.visible(current)) { 22 | fastdt.hide_panels(); 23 | } else { 24 | fastdt.hide_panels(); 25 | 26 | $$.show(current); 27 | this.parentElement.classList.add("fastdt-active"); 28 | 29 | const inner = current.querySelector(".fastDebugPanelContent .fastdt-scroll"), 30 | store_id = fastDebug.dataset.storeId; 31 | if (store_id && inner.children.length === 0) { 32 | const url = new URL(fastDebug.dataset.renderPanelUrl, window.location); 33 | url.searchParams.append("store_id", store_id); 34 | url.searchParams.append("panel_id", panelId); 35 | ajax(url).then(function (data) { 36 | inner.previousElementSibling.remove(); // Remove AJAX loader 37 | inner.innerHTML = data.content; 38 | $$.executeScripts(data.scripts); 39 | $$.applyStyles(inner); 40 | $$.loadScripts(inner); 41 | fastDebug.dispatchEvent( 42 | new CustomEvent("fastdt.panel.render", { 43 | detail: { panelId: panelId }, 44 | }), 45 | ); 46 | }); 47 | } else { 48 | fastDebug.dispatchEvent( 49 | new CustomEvent("fastdt.panel.render", { 50 | detail: { panelId: panelId }, 51 | }), 52 | ); 53 | } 54 | } 55 | }); 56 | $$.on(fastDebug, "click", ".fastDebugClose", function () { 57 | fastdt.hide_one_level(); 58 | }); 59 | $$.on(fastDebug, "click", ".fastDebugPanelButton input[type=checkbox]", function () { 60 | fastdt.cookie.set(this.dataset.cookie, this.checked ? "on" : "off", { 61 | path: "/", 62 | expires: 10, 63 | }); 64 | }); 65 | 66 | // Used by the SQL and template panels 67 | $$.on(fastDebug, "click", ".remoteCall", function (event) { 68 | event.preventDefault(); 69 | 70 | let url; 71 | const ajax_data = {}; 72 | 73 | if (this.tagName === "BUTTON") { 74 | const form = this.closest("form"); 75 | url = this.formAction; 76 | ajax_data.method = form.method.toUpperCase(); 77 | ajax_data.body = new FormData(form); 78 | } else if (this.tagName === "A") { 79 | url = this.href; 80 | } 81 | 82 | ajax(url, ajax_data).then(function (data) { 83 | const win = document.getElementById("fastDebugWindow"); 84 | win.innerHTML = data.content; 85 | $$.show(win); 86 | }); 87 | }); 88 | 89 | // Used by the cache and SQL panels 90 | $$.on(fastDebug, "click", ".fastToggleSwitch", function () { 91 | const id = this.dataset.toggleId; 92 | const toggleOpen = "+"; 93 | const toggleClose = "-"; 94 | const open_me = this.textContent === toggleOpen; 95 | const name = this.dataset.toggleName; 96 | const container = document.getElementById(name + "_" + id); 97 | container.querySelectorAll(".fastDebugCollapsed").forEach(function (e) { 98 | $$.toggle(e, open_me); 99 | }); 100 | container.querySelectorAll(".fastDebugUncollapsed").forEach(function (e) { 101 | $$.toggle(e, !open_me); 102 | }); 103 | const self = this; 104 | this.closest(".fastDebugPanelContent") 105 | .querySelectorAll(".fastToggleDetails_" + id) 106 | .forEach(function (e) { 107 | if (open_me) { 108 | e.classList.add("fastSelected"); 109 | e.classList.remove("fastUnselected"); 110 | self.textContent = toggleClose; 111 | } else { 112 | e.classList.remove("fastSelected"); 113 | e.classList.add("fastUnselected"); 114 | self.textContent = toggleOpen; 115 | } 116 | const switch_ = e.querySelector(".fastToggleSwitch"); 117 | if (switch_) { 118 | switch_.textContent = self.textContent; 119 | } 120 | }); 121 | }); 122 | 123 | document.getElementById("fastHideToolBarButton").addEventListener("click", function (event) { 124 | event.preventDefault(); 125 | fastdt.hide_toolbar(); 126 | }); 127 | document.getElementById("fastShowToolBarButton").addEventListener("click", function () { 128 | if (!fastdt.handleDragged) { 129 | fastdt.show_toolbar(); 130 | } 131 | }); 132 | let startPageY, baseY; 133 | const handle = document.getElementById("fastDebugToolbarHandle"); 134 | function onHandleMove(event) { 135 | // Chrome can send spurious mousemove events, so don't do anything unless the 136 | // cursor really moved. Otherwise, it will be impossible to expand the toolbar 137 | // due to fastdt.handleDragged being set to true. 138 | if (fastdt.handleDragged || event.pageY !== startPageY) { 139 | let top = baseY + event.pageY; 140 | 141 | if (top < 0) { 142 | top = 0; 143 | } else if (top + handle.offsetHeight > window.innerHeight) { 144 | top = window.innerHeight - handle.offsetHeight; 145 | } 146 | 147 | handle.style.top = top + "px"; 148 | fastdt.handleDragged = true; 149 | } 150 | } 151 | document.getElementById("fastShowToolBarButton").addEventListener("mousedown", function (event) { 152 | event.preventDefault(); 153 | startPageY = event.pageY; 154 | baseY = handle.offsetTop - startPageY; 155 | document.addEventListener("mousemove", onHandleMove); 156 | }); 157 | document.addEventListener("mouseup", function (event) { 158 | document.removeEventListener("mousemove", onHandleMove); 159 | if (fastdt.handleDragged) { 160 | event.preventDefault(); 161 | localStorage.setItem("fastdt.top", handle.offsetTop); 162 | requestAnimationFrame(function () { 163 | fastdt.handleDragged = false; 164 | }); 165 | fastdt.ensure_handle_visibility(); 166 | } 167 | }); 168 | const show = localStorage.getItem("fastdt.show") || fastDebug.dataset.defaultShow; 169 | if (show === "true") { 170 | fastdt.show_toolbar(); 171 | } else { 172 | fastdt.hide_toolbar(); 173 | } 174 | }, 175 | hide_panels() { 176 | const fastDebug = document.getElementById("fastDebug"); 177 | $$.hide(document.getElementById("fastDebugWindow")); 178 | fastDebug.querySelectorAll(".fastdt-panelContent").forEach(function (e) { 179 | $$.hide(e); 180 | }); 181 | document.querySelectorAll("#fastDebugToolbar li").forEach(function (e) { 182 | e.classList.remove("fastdt-active"); 183 | }); 184 | }, 185 | ensure_handle_visibility() { 186 | const handle = document.getElementById("fastDebugToolbarHandle"); 187 | // set handle position 188 | const handleTop = Math.min(localStorage.getItem("fastdt.top") || 0, window.innerHeight - handle.offsetWidth); 189 | handle.style.top = handleTop + "px"; 190 | }, 191 | hide_toolbar() { 192 | fastdt.hide_panels(); 193 | 194 | $$.hide(document.getElementById("fastDebugToolbar")); 195 | 196 | const handle = document.getElementById("fastDebugToolbarHandle"); 197 | $$.show(handle); 198 | fastdt.ensure_handle_visibility(); 199 | window.addEventListener("resize", fastdt.ensure_handle_visibility); 200 | document.removeEventListener("keydown", onKeyDown); 201 | 202 | localStorage.setItem("fastdt.show", "false"); 203 | }, 204 | hide_one_level() { 205 | const win = document.getElementById("fastDebugWindow"); 206 | if ($$.visible(win)) { 207 | $$.hide(win); 208 | } else { 209 | const toolbar = document.getElementById("fastDebugToolbar"); 210 | if (toolbar.querySelector("li.fastdt-active")) { 211 | fastdt.hide_panels(); 212 | } else { 213 | fastdt.hide_toolbar(); 214 | } 215 | } 216 | }, 217 | show_toolbar() { 218 | document.addEventListener("keydown", onKeyDown); 219 | $$.hide(document.getElementById("fastDebugToolbarHandle")); 220 | $$.show(document.getElementById("fastDebugToolbar")); 221 | localStorage.setItem("fastdt.show", "true"); 222 | window.removeEventListener("resize", fastdt.ensure_handle_visibility); 223 | }, 224 | cookie: { 225 | set(key, value, options) { 226 | options = options || {}; 227 | 228 | if (typeof options.expires === "number") { 229 | const days = options.expires, 230 | t = (options.expires = new Date()); 231 | t.setDate(t.getDate() + days); 232 | } 233 | 234 | document.cookie = [ 235 | encodeURIComponent(key) + "=" + String(value), 236 | options.expires ? "; expires=" + options.expires.toUTCString() : "", 237 | options.path ? "; path=" + options.path : "", 238 | options.domain ? "; domain=" + options.domain : "", 239 | options.secure ? "; secure" : "", 240 | "sameSite" in options ? "; sameSite=" + options.samesite : "; sameSite=Lax", 241 | ].join(""); 242 | 243 | return value; 244 | }, 245 | }, 246 | }; 247 | window.fastdt = { 248 | show_toolbar: fastdt.show_toolbar, 249 | hide_toolbar: fastdt.hide_toolbar, 250 | init: fastdt.init, 251 | close: fastdt.hide_one_level, 252 | cookie: fastdt.cookie, 253 | }; 254 | 255 | if (document.readyState !== "loading") { 256 | fastdt.init(); 257 | } else { 258 | document.addEventListener("DOMContentLoaded", fastdt.init); 259 | } 260 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/utils.js: -------------------------------------------------------------------------------- 1 | const $$ = { 2 | on(root, eventName, selector, fn) { 3 | root.addEventListener(eventName, function (event) { 4 | const target = event.target.closest(selector); 5 | if (root.contains(target)) { 6 | fn.call(target, event); 7 | } 8 | }); 9 | }, 10 | onPanelRender(root, panelId, fn) { 11 | /* 12 | This is a helper function to attach a handler for a `fastdt.panel.render` 13 | event of a specific panel. 14 | 15 | root: The container element that the listener should be attached to. 16 | panelId: The Id of the panel. 17 | fn: A function to execute when the event is triggered. 18 | */ 19 | root.addEventListener("fastdt.panel.render", function (event) { 20 | if (event.detail.panelId === panelId) { 21 | fn.call(event); 22 | } 23 | }); 24 | }, 25 | show(element) { 26 | element.classList.remove("fastdt-hidden"); 27 | }, 28 | hide(element) { 29 | element.classList.add("fastdt-hidden"); 30 | }, 31 | toggle(element, value) { 32 | if (value) { 33 | $$.show(element); 34 | } else { 35 | $$.hide(element); 36 | } 37 | }, 38 | visible(element) { 39 | return !element.classList.contains("fastdt-hidden"); 40 | }, 41 | executeScripts(scripts) { 42 | scripts.forEach(function (script) { 43 | const el = document.createElement("script"); 44 | el.type = "module"; 45 | el.src = script; 46 | el.async = true; 47 | document.head.appendChild(el); 48 | }); 49 | }, 50 | applyStyles(container) { 51 | /* 52 | * Given a container element, apply styles set via data-fastdt-styles attribute. 53 | * The format is data-fastdt-styles="styleName1:value;styleName2:value2" 54 | * The style names should use the CSSStyleDeclaration camel cased names. 55 | */ 56 | container.querySelectorAll("[data-fastdt-styles]").forEach(function (element) { 57 | const styles = element.dataset.fastdtStyles || ""; 58 | styles.split(";").forEach(function (styleText) { 59 | const styleKeyPair = styleText.split(":"); 60 | if (styleKeyPair.length === 2) { 61 | const name = styleKeyPair[0].trim(); 62 | const value = styleKeyPair[1].trim(); 63 | element.style[name] = value; 64 | } 65 | }); 66 | }); 67 | }, 68 | loadScripts(container) { 69 | container.querySelectorAll("script").forEach(function (element) { 70 | const script = document.createElement("script"); 71 | Array.from(element.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value)); 72 | script.appendChild(document.createTextNode(element.innerHTML)); 73 | element.parentNode.replaceChild(script, element); 74 | }); 75 | }, 76 | truncatechars(text, n) { 77 | return text.length > n ? `${text.slice(0, n)}...` : text; 78 | }, 79 | }; 80 | 81 | function ajax(url, init) { 82 | init = Object.assign({ credentials: "same-origin" }, init); 83 | return fetch(url, init) 84 | .then(function (response) { 85 | if (response.ok) { 86 | return response.json(); 87 | } 88 | return Promise.reject(new Error(response.status + ": " + response.statusText)); 89 | }) 90 | .catch(function (error) { 91 | const win = document.getElementById("fastDebugWindow"); 92 | win.innerHTML = ` 93 |
94 | 95 |

${error.message}

96 |
`; 97 | $$.show(win); 98 | throw error; 99 | }); 100 | } 101 | 102 | function ajaxForm(element) { 103 | const form = element.closest("form"); 104 | const url = new URL(form.action); 105 | const formData = new FormData(form); 106 | for (const [name, value] of formData.entries()) { 107 | url.searchParams.append(name, value); 108 | } 109 | const ajaxData = { 110 | method: form.method.toUpperCase(), 111 | }; 112 | return ajax(url, ajaxData); 113 | } 114 | 115 | export { $$, ajax, ajaxForm }; 116 | -------------------------------------------------------------------------------- /debug_toolbar/statics/js/versions.js: -------------------------------------------------------------------------------- 1 | import { $$ } from "./utils.js"; 2 | 3 | function pypiIndex() { 4 | const loader = document.getElementById("VersionsPanel").querySelector(".fastdt-loader"); 5 | 6 | function versionInfo(releases, version) { 7 | return ` 8 | ${version} 9 | 10 | ${new Date(releases[version]).toLocaleDateString()} 11 | `; 12 | } 13 | function link(url) { 14 | return ` 15 | 16 | ${$$.truncatechars(url, 40)} 17 | `; 18 | } 19 | function render(rowVersion, data) { 20 | if (rowVersion.textContent !== data.version) { 21 | rowVersion.parentNode.style.backgroundColor = loader.nextElementSibling.dataset.warningColor; 22 | } 23 | if (data.releases[rowVersion.textContent] !== null) { 24 | rowVersion.innerHTML = versionInfo(data.releases, rowVersion.textContent); 25 | } 26 | const lastVersion = rowVersion.nextElementSibling; 27 | lastVersion.innerHTML = versionInfo(data.releases, data.version); 28 | 29 | const python = lastVersion.nextElementSibling; 30 | python.innerHTML = data.requires_python; 31 | 32 | const status = python.nextElementSibling; 33 | status.innerHTML = data.status ? data.status.slice(26) : ""; 34 | 35 | if (data.home_page) { 36 | status.nextElementSibling.innerHTML = link(data.home_page); 37 | } 38 | } 39 | function getData(pypi) { 40 | return { 41 | version: pypi.info.version, 42 | requires_python: pypi.info.requires_python, 43 | home_page: pypi.info.home_page, 44 | releases: Object.fromEntries( 45 | Object.entries(pypi.releases).map(function ([k, v], i) { 46 | return [k, v.length ? v[0].upload_time : null]; 47 | }), 48 | ), 49 | status: pypi.info.classifiers.find(function (classifier) { 50 | return classifier.startsWith("Development Status"); 51 | }), 52 | }; 53 | } 54 | function updateRow(row) { 55 | return new Promise((resolve) => { 56 | const name = row.firstElementChild.textContent.trim(); 57 | const data = JSON.parse(localStorage.getItem(`pypi-${name}`)); 58 | const rowVersion = row.children.item(1); 59 | 60 | if (data === null || !(rowVersion.textContent in data.releases)) { 61 | fetch(`https://pypi.org/pypi/${name}/json`).then(function (response) { 62 | if (response.ok) { 63 | response.json().then(function (pypi) { 64 | const data = getData(pypi); 65 | 66 | if (!(rowVersion.textContent in data.releases)) { 67 | data.releases[rowVersion.textContent] = null; 68 | } 69 | localStorage.setItem(`pypi-${name}`, JSON.stringify(data)); 70 | render(rowVersion, data); 71 | resolve(); 72 | }); 73 | } 74 | }); 75 | } else { 76 | render(rowVersion, data); 77 | resolve(); 78 | } 79 | }); 80 | } 81 | if (loader && !loader.getAttribute("lock")) { 82 | loader.setAttribute("lock", true); 83 | 84 | const table = loader.nextElementSibling; 85 | const queryResult = table.querySelectorAll("tbody > tr"); 86 | const promises = []; 87 | 88 | for (let i = 0; i < queryResult.length; i++) { 89 | promises.push(updateRow(queryResult[i])); 90 | } 91 | Promise.all(promises).then(() => { 92 | loader.remove(); 93 | table.classList.remove("fastdt-hidden"); 94 | }); 95 | } 96 | } 97 | 98 | const fastDebug = document.getElementById("fastDebug"); 99 | pypiIndex(); 100 | $$.onPanelRender(fastDebug, "VersionsPanel", pypiIndex); 101 | -------------------------------------------------------------------------------- /debug_toolbar/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
12 |
13 |
    14 |
  • Hide »
  • 15 | {% for panel in toolbar.panels %} 16 | {% include "includes/panel_button.html" %} 17 | {% endfor %} 18 |
19 |
20 |
21 |
22 | 25 | DT 26 |
27 |
28 | 29 | {% for panel in toolbar.panels %} 30 | {% include "includes/panel_content.html" %} 31 | {% endfor %} 32 |
33 |
34 | -------------------------------------------------------------------------------- /debug_toolbar/templates/includes/panel_button.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 | {% if panel.has_content and panel.enabled %} 4 | 5 | {% else %} 6 | 18 | {% endif %} 19 |
  • 20 | -------------------------------------------------------------------------------- /debug_toolbar/templates/includes/panel_content.html: -------------------------------------------------------------------------------- 1 | {% if panel.has_content and panel.enabled %} 2 |
    3 |
    4 | 5 |

    {{ panel.title }}

    6 |
    7 |
    8 |
    9 |
    10 |
    11 |
    12 | {% endif %} 13 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/headers.html: -------------------------------------------------------------------------------- 1 |

    Request headers

    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% for key, value in request_headers.items()|sort %} 16 | 17 | 18 | 19 | 20 | {% endfor %} 21 | 22 |
    KeyValue
    {{ key|escape }}{{ value|escape }}
    23 | 24 |

    Response headers

    25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for key, value in response_headers.items()|sort %} 39 | 40 | 41 | 42 | 43 | {% endfor %} 44 | 45 |
    KeyValue
    {{ key|escape }}{{ value|escape }}
    46 | 47 |

    ASGI environ

    48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% for key, value in environ.items()|sort %} 58 | 59 | 60 | 61 | 62 | {% endfor %} 63 | 64 |
    KeyValue
    {{ key|escape }}{{ value|escape }}
    65 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/logging.html: -------------------------------------------------------------------------------- 1 | {% if records %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% for record in records %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
    LevelTimeChannelMessageLocation
    {{ record.level }}{{ record.time.strftime('%H:%M:%S %m/%d/%Y') }}{{ record.channel|default('-') }}{{ record.message }}{{ record.file }}:{{ record.line }}
    24 | {% else %} 25 |

    No messages logged.

    26 | {% endif %} 27 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/profiling.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/request.html: -------------------------------------------------------------------------------- 1 |

    Route information

    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 24 | 25 | 26 |
    NameEndpointPath params
    {{ request.route.name }}{{ get_name_from_obj(request.endpoint) }} 16 | {% if request.path_params %} 17 | {% for k, v in request.path_params.items() %} 18 | {{ k }}={{ v }}{% if not loop.last %}, {% endif %} 19 | {% endfor %} 20 | {% else %} 21 | - 22 | {% endif %} 23 |
    27 | 28 | {% macro pprint_vars(variables) %} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% for key, value in variables|sort %} 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 | 48 |
    VariableValue
    {{ key|pprint }}{{ value|pprint }}
    49 | {% endmacro %} 50 | 51 | 52 |

    Cookies

    53 | {% if request.cookies %} 54 | {{ pprint_vars(request.cookies.items()) }} 55 | {% else %} 56 |

    No cookies

    57 | {% endif %} 58 | 59 |

    Session data

    60 | {% if session %} 61 | {{ pprint_vars(session.items()) }} 62 | {% else %} 63 |

    No session data

    64 | {% endif %} 65 | 66 |

    GET data

    67 | {% if request.query_params %} 68 | {{ pprint_vars(request.query_params.multi_items()) }} 69 | {% else %} 70 |

    No GET data

    71 | {% endif %} 72 | 73 |

    POST data

    74 | {% if form %} 75 | {{ pprint_vars(form.multi_items()) }} 76 | {% else %} 77 |

    No POST data

    78 | {% endif %} 79 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/routes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for route in routes|sort(attribute='path') %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% endfor %} 21 | 22 |
    NameMethodsPathEndpointDescription
    {{ route.name|default('', true) }}{% if route.methods %}{{ route.methods|sort|join(', ') }}{% endif %}{{ route.path }}{{ get_name_from_obj(route.endpoint) }}{{ route.description }}
    23 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/settings.html: -------------------------------------------------------------------------------- 1 | {% macro pprint_settings(settings, exclude=None) %} 2 | {% if settings.model_config.title %}

    {{ settings.model_config.title }}

    {% endif %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for key, value in settings.model_dump(exclude=exclude).items() %} 13 | 14 | 15 | 16 | 17 | {% endfor %} 18 | 19 |
    KeyValue
    {{ key|escape }}{{ value|pprint|escape }}
    20 | {% endmacro %} 21 | 22 | {% for settings in toolbar.settings.SETTINGS %} 23 | {{ pprint_settings(settings) }} 24 | {% endfor %} 25 | 26 | {{ pprint_settings(toolbar.settings, exclude=to_set(['SETTINGS'])) }} 27 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/sql.html: -------------------------------------------------------------------------------- 1 | {% if queries %} 2 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {# TODO: #} 31 | 32 | 33 | 34 | {% for alias, query in queries %} 35 | 36 | 37 | 40 | 58 | 63 | 66 | 67 | 68 | 69 | 75 | 76 | {% endfor %} 77 | 78 |
    QueryTimelineTime (ms)Action
    38 | 39 | 41 |
    42 | {{ query.sql_formatted|safe }} 43 | {{ query.sql_simple|safe }} 44 |
    45 | {% if query.sim_count %} 46 | 47 | 48 | {{ query.sim_count }} similar queries. 49 | 50 | {% endif %} 51 | {% if query.dup_count %} 52 | 53 | 54 | Duplicated {{ query.dup_count }} times. 55 | 56 | {% endif %} 57 |
    59 | 60 | 61 | 62 | 64 | {{ '%0.2f'|format(query.duration|float) }} 65 |
    70 |
    71 | {% if query.params %}

    Params: {{ query.params }}

    {% endif %} 72 |

    Connection: {{ alias }}

    73 |
    74 |
    79 | {% else %} 80 |

    No SQL queries were recorded during this request.

    81 | {% endif %} 82 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/timer.html: -------------------------------------------------------------------------------- 1 |

    Resource usage

    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for key, value in rows %} 15 | 16 | 17 | 18 | 19 | {% endfor %} 20 | 21 |
    ResourceValue
    {{ key|escape }}{{ value|escape }}
    22 | 23 |
    24 |

    Browser timing

    25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
    Timing attributeTimelineMilliseconds since navigation start (+length)
    40 |
    41 | -------------------------------------------------------------------------------- /debug_toolbar/templates/panels/versions.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% for package in packages %} 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% endfor %} 29 | 30 |
    PackageVersionLatest versionPythonStatusHome page
    18 | 19 | {{ package.metadata.name }} 20 | 21 | {{ package.version }}
    31 | -------------------------------------------------------------------------------- /debug_toolbar/templates/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Debug Toolbar Redirects Panel: {{ status_code }} 5 | 6 | 7 | 8 |

    {{ status_line }}

    9 |

    Location: {{ redirect_to }}

    10 |

    11 | The Debug Toolbar has intercepted a redirect to the above URL for debug viewing purposes. You can click the above link to continue with the redirect as normal. 12 |

    13 | 14 | 15 | -------------------------------------------------------------------------------- /debug_toolbar/toolbar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | import uuid 5 | from collections import OrderedDict 6 | 7 | from anyio import create_task_group 8 | from fastapi import Request, Response 9 | from starlette.middleware.base import RequestResponseEndpoint 10 | 11 | from debug_toolbar.panels import Panel 12 | from debug_toolbar.settings import DebugToolbarSettings 13 | from debug_toolbar.types import ServerTiming, Stats 14 | from debug_toolbar.utils import get_name_from_obj, import_string 15 | 16 | DT = t.TypeVar("DT", bound="DebugToolbar") 17 | 18 | if t.TYPE_CHECKING: 19 | StoreDT = OrderedDict[str, DT] 20 | 21 | 22 | class DebugToolbar: 23 | _store: "StoreDT" = OrderedDict() 24 | _panel_classes: t.Sequence[t.Type[Panel]] | None = None 25 | 26 | def __init__( 27 | self, 28 | request: Request, 29 | call_next: RequestResponseEndpoint, 30 | settings: DebugToolbarSettings, 31 | ) -> None: 32 | self.request = request 33 | self.settings = settings 34 | panels = [] 35 | 36 | for panel_class in self.get_panel_classes( 37 | settings.DEFAULT_PANELS + settings.PANELS, 38 | )[::-1]: 39 | panel = panel_class(self, call_next) 40 | panels.append(panel) 41 | 42 | if panel.enabled: 43 | call_next = panel.process_request 44 | 45 | self.process_request = call_next 46 | self._panels = {} 47 | 48 | while panels: 49 | panel = panels.pop() 50 | self._panels[panel.panel_id] = panel 51 | 52 | self.stats: Stats = {} 53 | self.server_timing_stats: dict[str, ServerTiming] = {} 54 | self.store_id: str | None = None 55 | 56 | @classmethod 57 | def get_panel_classes( 58 | cls: t.Type[DT], 59 | panels: t.Sequence[str], 60 | ) -> t.Sequence[t.Type[Panel]]: 61 | if cls._panel_classes is None: 62 | cls._panel_classes = [import_string(panel_path) for panel_path in panels] 63 | return cls._panel_classes 64 | 65 | @property 66 | def panels(self) -> t.Sequence[Panel]: 67 | return list(self._panels.values()) 68 | 69 | def get_panel_by_id(self, panel_id: str) -> Panel: 70 | return self._panels[panel_id] 71 | 72 | @property 73 | def enabled_panels(self) -> t.Sequence[Panel]: 74 | return [panel for panel in self._panels.values() if panel.enabled] 75 | 76 | def store(self) -> None: 77 | if self.store_id: 78 | return 79 | 80 | self.store_id = uuid.uuid4().hex 81 | self._store[self.store_id] = self 82 | 83 | for _ in range(self.settings.RESULTS_CACHE_SIZE, len(self._store)): 84 | self._store.popitem(last=False) 85 | 86 | @classmethod 87 | def fetch(cls: t.Type[DT], store_id: str) -> DT | None: 88 | return cls._store.get(store_id) 89 | 90 | async def record_stats(self, response: Response) -> None: 91 | async with create_task_group() as tg: 92 | for panel in self.enabled_panels: 93 | tg.start_soon(panel.record_stats, self.request, response) 94 | 95 | async def record_server_timing(self, response: Response) -> None: 96 | async with create_task_group() as tg: 97 | for panel in self.enabled_panels: 98 | tg.start_soon(panel.record_server_timing, self.request, response) 99 | 100 | def refresh(self) -> dict[str, t.Any]: 101 | self.store() 102 | return { 103 | "storeId": self.store_id, 104 | "panels": { 105 | panel.panel_id: panel.nav_subtitle 106 | for panel in self.enabled_panels 107 | if panel.nav_subtitle 108 | }, 109 | } 110 | 111 | def generate_server_timing_header(self, response: Response) -> None: 112 | data = [] 113 | 114 | for panel in self.enabled_panels: 115 | stats = panel.get_server_timing_stats() 116 | if not stats: 117 | continue 118 | for key, title, value in stats: 119 | data.append(f'{panel.panel_id}_{key};dur={value};desc="{title}"') 120 | if data: 121 | response.headers["Server-Timing"] = ", ".join(data) 122 | 123 | def render(self, template: str, **context: t.Any) -> str: 124 | return self.settings.JINJA_ENV.get_template(template).render( 125 | toolbar=self, 126 | url_for=self.request.url_for, 127 | get_name_from_obj=get_name_from_obj, 128 | to_set=set, 129 | **context, 130 | ) 131 | 132 | def render_toolbar(self) -> str: 133 | self.store() 134 | return self.render("base.html") 135 | -------------------------------------------------------------------------------- /debug_toolbar/types.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | Stats = t.Dict[str, t.Any] 4 | ServerTiming = t.List[t.Sequence[t.Union[str, float]]] 5 | -------------------------------------------------------------------------------- /debug_toolbar/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import functools 5 | import inspect 6 | import sys 7 | import typing as t 8 | 9 | from fastapi import Request 10 | from fastapi.routing import APIRoute 11 | from pydantic_extra_types.color import Color 12 | from starlette.routing import Match 13 | 14 | 15 | def import_string(import_name: str) -> t.Any: 16 | try: 17 | __import__(import_name) 18 | except ImportError: 19 | if "." not in import_name: 20 | raise 21 | else: 22 | return sys.modules[import_name] 23 | 24 | module_name, obj_name = import_name.rsplit(".", 1) 25 | module = __import__(module_name, globals(), locals(), [obj_name]) 26 | try: 27 | return getattr(module, obj_name) 28 | except AttributeError as e: 29 | raise ImportError(e) from None 30 | 31 | 32 | def get_name_from_obj(obj: t.Any) -> str: 33 | if hasattr(obj, "__name__"): 34 | name = obj.__name__ 35 | else: 36 | name = obj.__class__.__name__ 37 | 38 | if hasattr(obj, "__module__"): 39 | name = f"{obj.__module__}.{name}" 40 | return name 41 | 42 | 43 | def matched_route(request: Request) -> APIRoute | None: 44 | for route in request.app.routes: 45 | match, _ = route.matches(request.scope) 46 | 47 | if match == Match.FULL: 48 | if hasattr(route, "endpoint"): 49 | return route 50 | break 51 | return None 52 | 53 | 54 | def is_coroutine(endpoint: t.Callable) -> bool: 55 | handler = endpoint 56 | 57 | while isinstance(handler, functools.partial): 58 | handler = handler.func 59 | if not (inspect.ismethod(handler) or inspect.isfunction(handler)): 60 | handler = handler.__call__ # type: ignore[operator] 61 | return asyncio.iscoroutinefunction(handler) 62 | 63 | 64 | def pluralize(value: int, arg: str = "s") -> str: 65 | if "," not in arg: 66 | arg = f",{arg}" 67 | 68 | bits = arg.split(",") 69 | 70 | if len(bits) > 2: 71 | return "" 72 | 73 | singular_suffix, plural_suffix = bits[:2] 74 | 75 | if value == 1: 76 | return singular_suffix 77 | return plural_suffix 78 | 79 | 80 | def color_generator() -> t.Generator[Color, None, None]: 81 | triples = [ 82 | (0, 1, 1), 83 | (1, 1, 0), 84 | (1, 0, 1), 85 | (1, 1, 1), 86 | (0, 1, 0), 87 | (0, 0, 1), 88 | ] 89 | n = 1 << 7 90 | so_far = [[0, 0, 0]] 91 | while True: 92 | if n == 0: 93 | yield Color("black") 94 | copy_so_far = list(so_far) 95 | for triple in triples: 96 | for previous in copy_so_far: 97 | rgb = [n * triple[i] + previous[i] for i in range(3)] 98 | so_far.append(rgb) 99 | yield Color(f"rgb{tuple(rgb)}") 100 | n >>= 1 101 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | fastapi-debug-toolbar.domake.io 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /docs/css/styles.css: -------------------------------------------------------------------------------- 1 | .md-header, 2 | .md-nav__source, 3 | .md-footer { 4 | background-color: #24292e; 5 | } 6 | 7 | .md-header .md-ellipsis > span { 8 | font-size: .6em; 9 | } 10 | 11 | .md-nav--primary { 12 | font-size: 1.8em; 13 | } 14 | 15 | .md-nav--secondary li > a[href^="#debug_toolbar.settings"] { 16 | text-transform: lowercase; 17 | } 18 | 19 | .md-nav__title, 20 | h4[id^="debug_toolbar.setting"] .doc-label-class-attribute { 21 | display: none; 22 | } 23 | 24 | h4[id^="debug_toolbar"] > code :first-child { 25 | font-weight: bold; 26 | text-transform: lowercase; 27 | } 28 | 29 | img.dt-tab { 30 | display: block; 31 | float: right; 32 | margin-top: -30px; 33 | } 34 | 35 | .doc > code span:nth-child(n+2) { 36 | font-size: .8em; 37 | } 38 | 39 | .doc-contents > p { 40 | padding-left: 30px; 41 | font-style: italic; 42 | font-size: .9em; 43 | color: #383838; 44 | } 45 | 46 | .md-typeset p > code { 47 | font-weight: bold; 48 | color: var(--md-primary-fg-color); 49 | background-color: #f7f7f9; 50 | padding: 2px 4px; 51 | border: 1px solid #e1e1e8; 52 | } 53 | -------------------------------------------------------------------------------- /docs/img/Swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/Swagger.png -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/panels/Headers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Headers.png -------------------------------------------------------------------------------- /docs/img/panels/Logging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Logging.png -------------------------------------------------------------------------------- /docs/img/panels/Profiling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Profiling.png -------------------------------------------------------------------------------- /docs/img/panels/Request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Request.png -------------------------------------------------------------------------------- /docs/img/panels/Routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Routes.png -------------------------------------------------------------------------------- /docs/img/panels/SQLAlchemy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/SQLAlchemy.png -------------------------------------------------------------------------------- /docs/img/panels/Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Settings.png -------------------------------------------------------------------------------- /docs/img/panels/Timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Timer.png -------------------------------------------------------------------------------- /docs/img/panels/Versions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/panels/Versions.png -------------------------------------------------------------------------------- /docs/img/tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/docs/img/tab.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | A debug toolbar for FastAPI based on the original [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar). 2 | 3 | **Swagger UI** & **GraphQL** are supported. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | pip install fastapi-debug-toolbar 9 | ``` 10 | 11 | !!! info 12 | 13 | The following packages are automatically installed: 14 | 15 | * [Jinja2](https://github.com/pallets/jinja) for toolbar templates. 16 | * [pyinstrument](https://github.com/joerick/pyinstrument) for profiling support. 17 | 18 | ## Quickstart 19 | 20 | Add `DebugToolbarMiddleware` middleware to your FastAPI application: 21 | 22 | ```py 23 | {!src/quickstart.py!} 24 | ``` 25 | 26 | ## How it works 27 | 28 | ![Debug toolbar](img/tab.png){ class=dt-tab } 29 | 30 | Once installed, the **debug toolbar tab** is displayed on the right side of any html page, just **click on it** to open the navbar. 31 | 32 | The debug toolbar can be used with [Swagger UI](https://swagger.io/tools/swagger-ui/) or [GraphiQL](https://github.com/graphql/graphiql) and it is automatically updated after any request using a cookie-based system. 33 | 34 | ![Swagger UI](img/Swagger.png) 35 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block htmltitle %} 4 | {{ config.site_name | striptags }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /docs/panels/default.md: -------------------------------------------------------------------------------- 1 | Here's a list of default panels available: 2 | 3 | ## Versions 4 | 5 | ![Versions panel](../img/panels/Versions.png) 6 | 7 | ## Timer 8 | 9 | ![Timer panels](../img/panels/Timer.png) 10 | 11 | ## Settings 12 | 13 | ![Settings panel](../img/panels/Settings.png) 14 | 15 | Add your pydantic's [BaseSettings](https://pydantic-docs.helpmanual.io/usage/settings/) classes to this panel: 16 | 17 | ```py hl_lines="11" 18 | {!src/panels/settings.py!} 19 | ``` 20 | 21 | ## Request 22 | 23 | ![Request panel](../img/panels/Request.png) 24 | 25 | ## Headers 26 | 27 | ![Headers panel](../img/panels/Headers.png) 28 | 29 | ## Routes 30 | 31 | ![Routes panel](../img/panels/Routes.png) 32 | 33 | ## Logging 34 | 35 | ![Logging panel](../img/panels/Logging.png) 36 | 37 | ## Profiling 38 | 39 | ![Profiling panel](../img/panels/Profiling.png) 40 | 41 | Profiling reports provided by [Pyinstrument](https://github.com/joerick/pyinstrument), you can configure the [profiler parameters](https://pyinstrument.readthedocs.io/en/latest/reference.html#pyinstrument.Profiler) by adding `profiler_options` settings: 42 | 43 | ```py hl_lines="5" 44 | {!src/panels/profiling.py!} 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/panels/dev.md: -------------------------------------------------------------------------------- 1 | ## First steps 2 | 3 | Before writing your own panel you need to provide a [Jinja loader](https://jinja.palletsprojects.com/en/latest/api/#loaders) instance used to load your templates from the file system or other locations. 4 | 5 | ```py hl_lines="10 11" 6 | {!src/dev/quickstart.py!} 7 | ``` 8 | 9 | ## Create a panel 10 | 11 | Subclass `Panel` and override `generate_stats()` method to implement a custom panel on your `panels.py`. 12 | This method should return a dict with the panel stats. 13 | 14 | ```py hl_lines="6 12" 15 | {!src/dev/panels.py!} 16 | ``` 17 | 18 | !!! tip 19 | 20 | The `process_request()` method is **optional** and particularly useful for adding behavior that occurs before the request is processed. 21 | 22 | Please see the [Panel](panel.md) class reference for further details. 23 | 24 | ## Writing the template 25 | 26 | Create a template at `templates/example.html` to display your panel stats: 27 | 28 | ```html 29 | {{ example }} 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/panels/panel.md: -------------------------------------------------------------------------------- 1 | ::: debug_toolbar.panels.Panel 2 | handlers: python 3 | options: 4 | heading_level: 4 5 | show_if_no_docstring: true 6 | show_root_heading: false 7 | show_root_toc_entry: false 8 | -------------------------------------------------------------------------------- /docs/panels/sql.md: -------------------------------------------------------------------------------- 1 | ## SQLAlchemy 2 | 3 | Add the `SQLAlchemyPanel` to your panel list: 4 | 5 | ```py hl_lines="8" 6 | {!src/panels/sqlalchemy/panel.py!} 7 | ``` 8 | 9 | ![SQLAlchemy panel](../img/panels/SQLAlchemy.png) 10 | 11 | This panel records all queries using the *"Dependency Injection"* system as described in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency). 12 | 13 | If you don't use dependencies then create a new class that inherits from `SQLAlchemyPanel`, override the `add_engines` method and add the class path to your panel list: 14 | 15 | ```py hl_lines="8 9" 16 | {!src/panels/sqlalchemy/add_engines.py!} 17 | ``` 18 | 19 | # ::: debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel 20 | handlers: python 21 | options: 22 | heading_level: 3 23 | show_bases: false 24 | show_root_heading: true 25 | members: 26 | - add_engines 27 | 28 | ## Tortoise ORM 29 | 30 | Add the `TortoisePanel` to your panel list: 31 | 32 | ```py hl_lines="8" 33 | {!src/panels/tortoise.py!} 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | Here's a list of settings available: 2 | 3 | ::: debug_toolbar.settings.DebugToolbarSettings 4 | handlers: python 5 | options: 6 | heading_level: 4 7 | show_bases: false 8 | show_if_no_docstring: true 9 | show_root_toc_entry: false 10 | filters: 11 | - "!^_" 12 | - "!Config$" 13 | - "!ci$" 14 | watch: 15 | - debug_toolbar/settings.py 16 | -------------------------------------------------------------------------------- /docs/src/dev/panels.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.panels import Panel 2 | 3 | 4 | class ExamplePanel(Panel): 5 | title = "Example Panel" 6 | template = "example.html" 7 | 8 | async def process_request(self, request): 9 | response = await super().process_request(request) 10 | return response 11 | 12 | async def generate_stats(self, request, response): 13 | return {"example": "value"} 14 | -------------------------------------------------------------------------------- /docs/src/dev/quickstart.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | from fastapi.templating import Jinja2Templates 4 | 5 | app = FastAPI(debug=True) 6 | templates = Jinja2Templates(directory="templates") 7 | 8 | app.add_middleware( 9 | DebugToolbarMiddleware, 10 | panels=["panels.ExamplePanel"], 11 | jinja_loaders=[templates.env.loader], 12 | ) 13 | -------------------------------------------------------------------------------- /docs/src/panels/profiling.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | 4 | app = FastAPI(debug=True) 5 | app.add_middleware(DebugToolbarMiddleware, profiler_options={'interval': .0002}) 6 | -------------------------------------------------------------------------------- /docs/src/panels/settings.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | from pydantic import SecretStr 4 | from pydantic_settings import BaseSettings 5 | 6 | 7 | class APISettings(BaseSettings): 8 | SECRET_KEY: SecretStr 9 | 10 | 11 | app = FastAPI(debug=True) 12 | app.add_middleware(DebugToolbarMiddleware, settings=[APISettings()]) 13 | -------------------------------------------------------------------------------- /docs/src/panels/sqlalchemy/add_engines.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.panels.sqlalchemy import SQLAlchemyPanel as BasePanel 2 | from sqlalchemy import create_engine 3 | 4 | engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) 5 | 6 | 7 | class SQLAlchemyPanel(BasePanel): 8 | async def add_engines(self, request: Request): 9 | self.engines.add(engine) 10 | -------------------------------------------------------------------------------- /docs/src/panels/sqlalchemy/panel.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | 4 | app = FastAPI(debug=True) 5 | 6 | app.add_middleware( 7 | DebugToolbarMiddleware, 8 | panels=["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"], 9 | ) 10 | -------------------------------------------------------------------------------- /docs/src/panels/tortoise.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | 4 | app = FastAPI(debug=True) 5 | 6 | app.add_middleware( 7 | DebugToolbarMiddleware, 8 | panels=["debug_toolbar.panels.tortoise.TortoisePanel"], 9 | ) 10 | -------------------------------------------------------------------------------- /docs/src/quickstart.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.middleware import DebugToolbarMiddleware 2 | from fastapi import FastAPI 3 | 4 | app = FastAPI(debug=True) 5 | app.add_middleware(DebugToolbarMiddleware) 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI Debug Toolbar 2 | site_description: A debug toolbar for FastAPI. 3 | site_url: https://fastapi-debug-toolbar.domake.io 4 | 5 | repo_name: mongkok/fastapi-debug-toolbar 6 | repo_url: https://github.com/mongkok/fastapi-debug-toolbar 7 | edit_uri: '' 8 | 9 | theme: 10 | name: material 11 | custom_dir: docs/overrides 12 | logo: img/logo.png 13 | favicon: img/favicon.ico 14 | icon: 15 | repo: fontawesome/brands/github-alt 16 | palette: 17 | - scheme: default 18 | primary: red 19 | accent: red 20 | 21 | nav: 22 | - Introduction: index.md 23 | - Default panels: panels/default.md 24 | - SQL panels: panels/sql.md 25 | - Development: panels/dev.md 26 | - Settings: settings.md 27 | - Changelog: changelog.md 28 | 29 | extra: 30 | social: 31 | - icon: fontawesome/brands/github-alt 32 | link: https://github.com/mongkok 33 | 34 | extra_css: 35 | - css/styles.css 36 | 37 | markdown_extensions: 38 | - admonition 39 | - pymdownx.superfences 40 | - pymdownx.snippets 41 | - attr_list 42 | - markdown_include.include: 43 | base_path: docs 44 | 45 | plugins: 46 | - search 47 | - mkdocstrings 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "fastapi-debug-toolbar" 7 | description = "A debug toolbar for FastAPI." 8 | readme = "README.md" 9 | license = "BSD-3-Clause" 10 | requires-python = ">=3.8" 11 | dynamic = ["version"] 12 | authors = [ 13 | { name = "Dani", email = "dani@domake.io" } 14 | ] 15 | keywords = ["fastapi", "debug", "profiling"] 16 | 17 | classifiers = [ 18 | "License :: OSI Approved :: BSD License", 19 | "Development Status :: 2 - Pre-Alpha", 20 | "Framework :: AnyIO", 21 | "Framework :: FastAPI", 22 | "Environment :: Web Environment", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: System Administrators", 25 | "Operating System :: OS Independent", 26 | "Typing :: Typed", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Topic :: Software Development", 35 | "Topic :: Software Development :: Debuggers", 36 | "Topic :: Software Development :: Libraries", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | "Topic :: Internet", 39 | "Topic :: Internet :: WWW/HTTP", 40 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 41 | "Topic :: Utilities", 42 | ] 43 | 44 | dependencies = [ 45 | "fastapi >=0.106.0", 46 | "anyio >=3.0.0", 47 | "Jinja2 >=2.9", 48 | "pydantic >=2.0", 49 | "pydantic-extra-types >=2.0.0", 50 | "pydantic-settings >=2.0.0", 51 | "pyinstrument >=3.0.0", 52 | "sqlparse >=0.2.0", 53 | ] 54 | 55 | [project.urls] 56 | homepage = "https://github.com/mongkok/fastapi-debug-toolbar" 57 | repository = "https://github.com/mongkok/fastapi-debug-toolbar" 58 | documentation = "https://fastapi-debug-toolbar.domake.io" 59 | changelog = "https://fastapi-debug-toolbar.domake.io/changelog/" 60 | 61 | [project.optional-dependencies] 62 | test = [ 63 | "bandit", 64 | "coverage", 65 | "httpx", 66 | "itsdangerous", 67 | "mypy", 68 | "pytest", 69 | "pytest-asyncio", 70 | "python-multipart", 71 | "ruff", 72 | "SQLAlchemy", 73 | "tortoise-orm", 74 | ] 75 | 76 | doc = [ 77 | "mkdocs", 78 | "mkdocs-material", 79 | "markdown-include", 80 | "mkdocstrings[python]", 81 | ] 82 | 83 | [tool.hatch.build.targets.sdist] 84 | include = ["debug_toolbar"] 85 | 86 | [tool.hatch.build.targets.wheel] 87 | packages = ["debug_toolbar"] 88 | 89 | [tool.hatch.version] 90 | path = "debug_toolbar/__init__.py" 91 | 92 | [[tool.mypy.overrides]] 93 | module = ["sqlparse"] 94 | ignore_missing_imports = true 95 | 96 | [tool.ruff.lint] 97 | select = [ 98 | "E", # pycodestyle errors 99 | "W", # pycodestyle warnings 100 | "F", # pyflakes 101 | "I", # isort 102 | "C", # flake8-comprehensions 103 | "B", # flake8-bugbear 104 | ] 105 | 106 | [tool.ruff.lint.flake8-bugbear] 107 | extend-immutable-calls = ["fastapi.Depends"] 108 | 109 | [tool.bandit] 110 | skips = ["B101"] 111 | 112 | [tool.pytest.ini_options] 113 | addopts = ["--strict-config", "--strict-markers"] 114 | asyncio_mode = "auto" 115 | 116 | [tool.coverage.run] 117 | source_pkgs = ["debug_toolbar", "tests"] 118 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | ruff debug_toolbar tests scripts 7 | ruff format debug_toolbar tests --check 8 | bandit --configfile pyproject.toml --recursive debug_toolbar 9 | mypy --install-types --non-interactive debug_toolbar tests 10 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | scripts/lint 7 | coverage run -m pytest tests $@ 8 | coverage report --show-missing 9 | coverage xml 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | from fastapi import FastAPI, Request 7 | from fastapi.responses import HTMLResponse 8 | from fastapi.templating import Jinja2Templates 9 | 10 | from debug_toolbar.middleware import DebugToolbarMiddleware 11 | from debug_toolbar.toolbar import DebugToolbar 12 | 13 | from .testclient import TestClient 14 | 15 | 16 | @pytest.fixture 17 | def settings() -> dict[str, t.Any]: 18 | return {} 19 | 20 | 21 | @pytest.fixture 22 | async def app(settings: dict[str, t.Any]) -> FastAPI: 23 | settings.setdefault("default_panels", []) 24 | settings.setdefault("disable_panels", []) 25 | DebugToolbar._panel_classes = None 26 | 27 | _app = FastAPI(debug=True) 28 | _app.add_middleware(DebugToolbarMiddleware, **settings) 29 | return _app 30 | 31 | 32 | @pytest.fixture 33 | def templates() -> Jinja2Templates: 34 | return Jinja2Templates(directory="tests/templates") 35 | 36 | 37 | @pytest.fixture 38 | def get_index(templates: Jinja2Templates) -> t.Callable: 39 | def f(request: Request) -> HTMLResponse: 40 | return templates.TemplateResponse(name="index.html", request=request) 41 | 42 | return f 43 | 44 | 45 | @pytest.fixture 46 | def client(app: FastAPI, get_index: t.Callable) -> TestClient: 47 | app.get("/sync", response_class=HTMLResponse)(get_index) 48 | 49 | @app.get("/async", response_class=HTMLResponse) 50 | async def get_async(request: Request) -> HTMLResponse: 51 | return get_index(request) 52 | 53 | return TestClient(app) 54 | -------------------------------------------------------------------------------- /tests/mark.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | from _pytest.mark import MarkDecorator 7 | 8 | 9 | def override_settings(**settings: t.Any) -> MarkDecorator: 10 | return pytest.mark.parametrize("settings", [settings]) 11 | 12 | 13 | def override_panels(panels: list[str]) -> MarkDecorator: 14 | return override_settings(panels=panels) 15 | -------------------------------------------------------------------------------- /tests/panels/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/tests/panels/__init__.py -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/tests/panels/sqlalchemy/__init__.py -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | from fastapi import Depends, FastAPI, Request 7 | from fastapi.responses import HTMLResponse 8 | from sqlalchemy.orm import Session, sessionmaker 9 | 10 | from ...testclient import TestClient 11 | from .crud import create_user, get_user 12 | from .database import Base, engine 13 | 14 | Base.metadata.create_all(bind=engine) 15 | 16 | 17 | @pytest.fixture 18 | def get_db(session_options: dict[str, t.Any]) -> t.Callable: 19 | SessionLocal = sessionmaker(**session_options or {"bind": engine}) 20 | 21 | def f() -> t.Generator: 22 | db = SessionLocal() 23 | try: 24 | yield db 25 | finally: 26 | db.close() 27 | 28 | return f 29 | 30 | 31 | @pytest.fixture 32 | def client(app: FastAPI, get_index: t.Callable, get_db: t.Callable) -> TestClient: 33 | @app.get("/sql", response_class=HTMLResponse) 34 | async def get_sql(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: 35 | user = create_user(db=db, username=str(id(get_db))) 36 | get_user(db=db, user_id=user.id) 37 | get_user(db=db, user_id=user.id) 38 | return get_index(request) 39 | 40 | return TestClient(app) 41 | -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/crud.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy import Column 4 | from sqlalchemy.orm import Session 5 | 6 | from . import models 7 | 8 | 9 | def create_user(db: Session, username: str) -> models.User: 10 | user = models.User(username=username) 11 | db.add(user) 12 | db.commit() 13 | return user 14 | 15 | 16 | def get_user(db: Session, user_id: Column[int]) -> models.User | None: 17 | return db.query(models.User).filter(models.User.id == user_id).first() 18 | -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import declarative_base 3 | from sqlalchemy.pool import StaticPool 4 | 5 | engine = create_engine( 6 | "sqlite://", 7 | connect_args={"check_same_thread": False}, 8 | poolclass=StaticPool, 9 | ) 10 | Base = declarative_base() 11 | -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | 3 | from .database import Base 4 | 5 | 6 | class User(Base): 7 | __tablename__ = "users" 8 | 9 | id = Column(Integer, primary_key=True) 10 | username = Column(String, unique=True) 11 | -------------------------------------------------------------------------------- /tests/panels/sqlalchemy/test_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | from sqlalchemy.orm import declarative_base 7 | 8 | from ...mark import override_panels 9 | from ...testclient import TestClient 10 | from .database import Base, engine 11 | 12 | 13 | @override_panels(["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"]) 14 | @pytest.mark.parametrize( 15 | "session_options", 16 | (None, {"binds": {Base: engine, declarative_base(): engine}}), 17 | ) 18 | def test_sqlalchemy(client: TestClient, session_options: dict[str, t.Any]) -> None: 19 | store_id = client.get_store_id("/sql") 20 | stats = client.get_stats(store_id, "SQLAlchemyPanel") 21 | queries = stats["queries"] 22 | 23 | assert len(queries) == 4 24 | assert queries[0][1]["sql"].startswith("INSERT") 25 | assert queries[2][1]["dup_count"] == 2 26 | -------------------------------------------------------------------------------- /tests/panels/test_headers.py: -------------------------------------------------------------------------------- 1 | from ..mark import override_panels 2 | from ..testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.headers.HeadersPanel"]) 6 | def test_headers(client: TestClient) -> None: 7 | headers = { 8 | "cookie": "", 9 | } 10 | store_id = client.get_store_id("/async", headers=headers) 11 | stats = client.get_stats(store_id, "HeadersPanel") 12 | request_headers = stats["request_headers"] 13 | 14 | assert request_headers["cookie"] 15 | -------------------------------------------------------------------------------- /tests/panels/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | 4 | import pytest 5 | from fastapi import FastAPI, Request 6 | from fastapi.logger import logger 7 | from fastapi.responses import HTMLResponse 8 | 9 | from ..mark import override_panels 10 | from ..testclient import TestClient 11 | 12 | 13 | @pytest.fixture 14 | def client(app: FastAPI, get_index: t.Callable) -> TestClient: 15 | @app.get("/log/sync", response_class=HTMLResponse) 16 | def get_log(request: Request, level: str) -> HTMLResponse: 17 | logger.log(logging._nameToLevel[level], "") 18 | return get_index(request) 19 | 20 | @app.get("/log/async", response_class=HTMLResponse) 21 | async def get_log_async(request: Request, level: str) -> HTMLResponse: 22 | return get_log(request, level) 23 | 24 | return TestClient(app) 25 | 26 | 27 | @pytest.mark.parametrize("level", ["ERROR", "WARNING"]) 28 | @pytest.mark.parametrize("path", ["sync", "async"]) 29 | @override_panels(["debug_toolbar.panels.logging.LoggingPanel"]) 30 | def test_logging(client: TestClient, path: str, level: str) -> None: 31 | store_id = client.get_store_id(f"/log/{path}", params={"level": level}) 32 | stats = client.get_stats(store_id, "LoggingPanel") 33 | 34 | assert stats["records"][0]["level"] == level 35 | -------------------------------------------------------------------------------- /tests/panels/test_profiling.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ..mark import override_panels 4 | from ..testclient import TestClient 5 | 6 | 7 | @pytest.mark.parametrize("path", ["sync", "async"]) 8 | @override_panels(["debug_toolbar.panels.profiling.ProfilingPanel"]) 9 | def test_profiling(client: TestClient, path: str) -> None: 10 | store_id = client.get_store_id(f"/{path}") 11 | stats = client.get_stats(store_id, "ProfilingPanel") 12 | 13 | assert "pyinstrumentHTMLRenderer" in stats["content"] 14 | -------------------------------------------------------------------------------- /tests/panels/test_redirects.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI, status 3 | from fastapi.responses import RedirectResponse 4 | 5 | from ..mark import override_panels 6 | from ..testclient import TestClient 7 | 8 | 9 | @pytest.fixture 10 | def client(app: FastAPI) -> TestClient: 11 | @app.get( 12 | "/redirect", 13 | response_class=RedirectResponse, 14 | status_code=status.HTTP_302_FOUND, 15 | ) 16 | async def redirect() -> str: 17 | return "http://" 18 | 19 | return TestClient(app) 20 | 21 | 22 | @override_panels(["debug_toolbar.panels.redirects.RedirectsPanel"]) 23 | def test_redirects(client: TestClient) -> None: 24 | headers = { 25 | "Location": "http://", 26 | } 27 | store_id = client.get_store_id("/redirect", headers=headers) 28 | response = client.render_panel(store_id, "RedirectsPanel") 29 | 30 | assert response.status_code == status.HTTP_200_OK 31 | assert not response.json()["content"] 32 | -------------------------------------------------------------------------------- /tests/panels/test_request.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | from fastapi import FastAPI, Request 5 | from fastapi.responses import HTMLResponse 6 | from starlette.middleware.sessions import SessionMiddleware 7 | 8 | from ..mark import override_panels 9 | from ..testclient import TestClient 10 | 11 | 12 | @pytest.fixture 13 | def client(app: FastAPI, get_index: t.Callable) -> TestClient: 14 | app.add_middleware(SessionMiddleware, secret_key="") 15 | 16 | @app.get("/session", response_class=HTMLResponse) 17 | async def get_session(request: Request) -> HTMLResponse: 18 | request.session["debug"] = True 19 | return get_index(request) 20 | 21 | return TestClient(app) 22 | 23 | 24 | @override_panels(["debug_toolbar.panels.request.RequestPanel"]) 25 | def test_session(client: TestClient) -> None: 26 | store_id = client.get_store_id("/session") 27 | stats = client.get_stats(store_id, "RequestPanel") 28 | 29 | assert stats["session"]["debug"] 30 | -------------------------------------------------------------------------------- /tests/panels/test_routes.py: -------------------------------------------------------------------------------- 1 | from ..mark import override_panels 2 | from ..testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.routes.RoutesPanel"]) 6 | def test_routes(client: TestClient) -> None: 7 | store_id = client.get_store_id("/async") 8 | stats = client.get_stats(store_id, "RoutesPanel") 9 | 10 | assert any(route.name == "openapi" for route in stats["routes"]) 11 | -------------------------------------------------------------------------------- /tests/panels/test_settings.py: -------------------------------------------------------------------------------- 1 | from ..mark import override_panels 2 | from ..testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.settings.SettingsPanel"]) 6 | def test_settings(client: TestClient) -> None: 7 | store_id = client.get_store_id("/async") 8 | stats = client.get_stats(store_id, "SettingsPanel") 9 | 10 | assert not stats 11 | -------------------------------------------------------------------------------- /tests/panels/test_timer.py: -------------------------------------------------------------------------------- 1 | from ..mark import override_panels 2 | from ..testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"]) 6 | def test_timer(client: TestClient) -> None: 7 | store_id = client.get_store_id("/async") 8 | stats = client.get_stats(store_id, "TimerPanel") 9 | 10 | assert "total" in stats 11 | -------------------------------------------------------------------------------- /tests/panels/test_versions.py: -------------------------------------------------------------------------------- 1 | from ..mark import override_panels 2 | from ..testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.versions.VersionsPanel"]) 6 | def test_versions(client: TestClient) -> None: 7 | store_id = client.get_store_id("/async") 8 | stats = client.get_stats(store_id, "VersionsPanel") 9 | 10 | assert stats["packages"] 11 | -------------------------------------------------------------------------------- /tests/panels/tortoise/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/f6a2ee2699143ed1a30fcc11c21b7aa591ac9968/tests/panels/tortoise/__init__.py -------------------------------------------------------------------------------- /tests/panels/tortoise/conftest.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | from fastapi import FastAPI, Request 5 | from fastapi.responses import HTMLResponse 6 | from tortoise import Tortoise 7 | 8 | from ...testclient import TestClient 9 | from .crud import create_user, get_user 10 | 11 | 12 | @pytest.fixture 13 | async def client( 14 | app: FastAPI, 15 | get_index: t.Callable, 16 | ) -> t.AsyncGenerator[TestClient, None]: 17 | @app.get("/sql", response_class=HTMLResponse) 18 | async def get_sql(request: Request) -> HTMLResponse: 19 | user = await create_user(username="test") 20 | await get_user(user_id=user.id) 21 | await get_user(user_id=user.id) 22 | return get_index(request) 23 | 24 | await Tortoise.init( 25 | db_url="sqlite://:memory:", 26 | modules={"models": ["tests.panels.tortoise.models"]}, 27 | ) 28 | await Tortoise.generate_schemas() 29 | yield TestClient(app) 30 | await Tortoise.close_connections() 31 | -------------------------------------------------------------------------------- /tests/panels/tortoise/crud.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from tortoise.queryset import QuerySetSingle 4 | 5 | from . import models 6 | 7 | 8 | def create_user(username: str) -> t.Coroutine[t.Any, t.Any, models.User]: 9 | return models.User.create(username=username) 10 | 11 | 12 | def get_user(user_id: int) -> QuerySetSingle[models.User]: 13 | return models.User.get(id=user_id) 14 | -------------------------------------------------------------------------------- /tests/panels/tortoise/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields, models 2 | 3 | 4 | class User(models.Model): 5 | id = fields.IntField(pk=True) 6 | username = fields.CharField(max_length=20, unique=True) 7 | -------------------------------------------------------------------------------- /tests/panels/tortoise/test_tortoise.py: -------------------------------------------------------------------------------- 1 | from ...mark import override_panels 2 | from ...testclient import TestClient 3 | 4 | 5 | @override_panels(["debug_toolbar.panels.tortoise.TortoisePanel"]) 6 | def test_tortoise(client: TestClient) -> None: 7 | store_id = client.get_store_id("/sql") 8 | stats = client.get_stats(store_id, "TortoisePanel") 9 | queries = stats["queries"] 10 | 11 | assert len(queries) == 3 12 | assert queries[0][1]["sql"].startswith("INSERT") 13 | assert queries[1][1]["dup_count"] == 2 14 | -------------------------------------------------------------------------------- /tests/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | 3 | from .mark import override_panels 4 | from .testclient import TestClient 5 | 6 | 7 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"]) 8 | def test_render_panel(client: TestClient) -> None: 9 | store_id = client.get_store_id("/async") 10 | response = client.render_panel(store_id=store_id, panel_id="TimerPanel") 11 | 12 | assert response.status_code == status.HTTP_200_OK 13 | assert response.json()["scripts"] 14 | 15 | 16 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"]) 17 | def test_invalid_store_id(client: TestClient) -> None: 18 | response = client.render_panel(store_id="", panel_id="TimerPanel") 19 | 20 | assert response.status_code == status.HTTP_200_OK 21 | assert not response.json()["scripts"] 22 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, status 2 | 3 | from debug_toolbar.middleware import show_toolbar 4 | from debug_toolbar.settings import DebugToolbarSettings 5 | 6 | from .testclient import TestClient 7 | 8 | 9 | def test_sync(client: TestClient) -> None: 10 | assert client.get_store_id("/sync") 11 | 12 | 13 | def test_async(client: TestClient) -> None: 14 | assert client.get_store_id("/async") 15 | 16 | 17 | def test_json(client: TestClient) -> None: 18 | assert client.get_store_id("/openapi.json") 19 | 20 | 21 | def test_404(client: TestClient) -> None: 22 | response = client.get("/404") 23 | assert response.status_code == status.HTTP_404_NOT_FOUND 24 | 25 | 26 | def test_show_toolbar_not_allowed(app: FastAPI) -> None: 27 | scope = { 28 | "app": app, 29 | "type": "http", 30 | "client": ("invalid", 80), 31 | } 32 | request = Request(scope=scope) 33 | settings = DebugToolbarSettings(ALLOWED_HOSTS=["test"]) 34 | assert not show_toolbar(request, settings) 35 | -------------------------------------------------------------------------------- /tests/testclient.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import typing as t 5 | from urllib import parse 6 | 7 | import httpx 8 | from fastapi import status 9 | from fastapi.testclient import TestClient as BaseTestClient 10 | 11 | from debug_toolbar.toolbar import DebugToolbar 12 | from debug_toolbar.types import Stats 13 | 14 | 15 | class TestClient(BaseTestClient): 16 | def get_store_id( 17 | self, 18 | path: str, 19 | method: str | None = None, 20 | **kwargs: t.Any, 21 | ) -> str: 22 | response = getattr(self, (method or "get"))(path, **kwargs) 23 | 24 | if response.headers["content-type"].startswith("text/html"): 25 | return re.findall(r'store-id="(.+?)"', response.content.decode())[0] 26 | 27 | cookie = parse.unquote(response.headers["set-cookie"]) 28 | return re.findall(r'"storeId": "(.+?)"', cookie)[0] 29 | 30 | def render_panel(self, store_id: str, panel_id: str) -> httpx.Response: 31 | return self.get( 32 | "/_debug_toolbar", 33 | params={"store_id": store_id, "panel_id": panel_id}, 34 | ) 35 | 36 | def get_stats(self, store_id: str, panel_id: str) -> Stats: 37 | response = self.render_panel(store_id, panel_id) 38 | assert response.status_code == status.HTTP_200_OK 39 | 40 | toolbar = DebugToolbar.fetch(store_id) 41 | assert toolbar 42 | return toolbar.stats[panel_id] 43 | --------------------------------------------------------------------------------